Compare commits

..
131 changed files with 6761 additions and 889 deletions
Generated
+1
View File
@@ -2040,6 +2040,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"sha2 0.11.0",
"tokio",
"tokio-util",
"tower",
+27
View File
@@ -12059,6 +12059,9 @@
"properties": {
"status": {"type": "string", "minLength": 1, "maxLength": 256},
"sessions": {"$ref": "#/components/schemas/Int32Type"},
"session_resumes_total": {"type": "integer", "minimum": 0, "maximum": 9007199254740991},
"websocket_dispatches_total": {"type": "integer", "minimum": 0, "maximum": 9007199254740991},
"websocket_dispatch_drops_total": {"type": "integer", "minimum": 0, "maximum": 9007199254740991},
"guilds": {"$ref": "#/components/schemas/Int32Type"},
"presences": {"$ref": "#/components/schemas/Int32Type"},
"calls": {"$ref": "#/components/schemas/Int32Type"},
@@ -12085,6 +12088,24 @@
"node_id": {"type": "string", "minLength": 1, "maxLength": 256},
"status": {"type": "string", "minLength": 1, "maxLength": 256},
"sessions": {"$ref": "#/components/schemas/Int32Type"},
"session_resumes_total": {
"nullable": true,
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"websocket_dispatches_total": {
"nullable": true,
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"websocket_dispatch_drops_total": {
"nullable": true,
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"guilds": {"$ref": "#/components/schemas/Int32Type"},
"presences": {"$ref": "#/components/schemas/Int32Type"},
"calls": {"$ref": "#/components/schemas/Int32Type"},
@@ -12138,6 +12159,9 @@
"node_id",
"status",
"sessions",
"session_resumes_total",
"websocket_dispatches_total",
"websocket_dispatch_drops_total",
"guilds",
"presences",
"calls",
@@ -12153,6 +12177,9 @@
"required": [
"status",
"sessions",
"session_resumes_total",
"websocket_dispatches_total",
"websocket_dispatch_drops_total",
"guilds",
"presences",
"calls",
@@ -25,6 +25,10 @@ pub(crate) fn stat_card(label: &str, value: &str) -> Markup {
pub(crate) fn node_stats_section(data: &serde_json::Value, expanded: bool, base: &str) -> Markup {
let sessions = data.get("sessions").and_then(|v| v.as_u64()).unwrap_or(0);
let reconnects: u64 = data
.get("session_resumes_total")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let guilds = data.get("guilds").and_then(|v| v.as_u64()).unwrap_or(0);
let presences = data.get("presences").and_then(|v| v.as_u64()).unwrap_or(0);
let calls = data.get("calls").and_then(|v| v.as_u64()).unwrap_or(0);
@@ -64,6 +68,7 @@ pub(crate) fn node_stats_section(data: &serde_json::Value, expanded: bool, base:
div class="grid grid-cols-2 gap-3 sm:gap-4 md:grid-cols-3 lg:grid-cols-6" {
(stat_card("Nodes", &node_count.to_string()))
(stat_card("Sessions", &sessions.to_string()))
(stat_card("Reconnects", &reconnects.to_string()))
(stat_card("Guilds", &guilds.to_string()))
(stat_card("Presences", &presences.to_string()))
(stat_card("Calls", &calls.to_string()))
@@ -83,6 +88,7 @@ pub(crate) fn node_stats_table(nodes: &[serde_json::Value]) -> Markup {
tr {
th class="px-6 py-3 text-left text-neutral-600 text-xs uppercase" { "Node" }
th class="px-6 py-3 text-right text-neutral-600 text-xs uppercase" { "Sessions" }
th class="px-6 py-3 text-right text-neutral-600 text-xs uppercase" { "Session Resumes" }
th class="px-6 py-3 text-right text-neutral-600 text-xs uppercase" { "Guilds" }
th class="px-6 py-3 text-right text-neutral-600 text-xs uppercase" { "Presences" }
th class="px-6 py-3 text-right text-neutral-600 text-xs uppercase" { "Calls" }
@@ -95,6 +101,7 @@ pub(crate) fn node_stats_table(nodes: &[serde_json::Value]) -> Markup {
@let label = format_node_id(node_id, i);
@let status = node.get("status").and_then(|v| v.as_str()).unwrap_or("-");
@let ns = node.get("sessions").and_then(|v| v.as_u64()).unwrap_or(0);
@let nsr = node.get("session_resumes_total").and_then(|v| v.as_u64()).unwrap_or(0);
@let ng = node.get("guilds").and_then(|v| v.as_u64()).unwrap_or(0);
@let np = node.get("presences").and_then(|v| v.as_u64()).unwrap_or(0);
@let nc = node.get("calls").and_then(|v| v.as_u64()).unwrap_or(0);
@@ -105,6 +112,7 @@ pub(crate) fn node_stats_table(nodes: &[serde_json::Value]) -> Markup {
div class="text-neutral-500 text-xs" { (status) }
}
td class="whitespace-nowrap px-6 py-4 text-right text-sm" { (ns) }
td class="whitespace-nowrap px-6 py-4 text-right text-sm" { (nsr) }
td class="whitespace-nowrap px-6 py-4 text-right text-sm" { (ng) }
td class="whitespace-nowrap px-6 py-4 text-right text-sm" { (np) }
td class="whitespace-nowrap px-6 py-4 text-right text-sm" { (nc) }
@@ -1744,6 +1744,8 @@ export class GatewayService {
}
async getNodeStats(): Promise<GatewayNodeStats> {
const a = this.call<GatewayNodeStats>('process.node_stats', {});
console.log(a.then((stats) => console.log('Node stats:', stats)));
return this.call<GatewayNodeStats>('process.node_stats', {});
}
@@ -62,6 +62,9 @@ export interface GatewayNodeStatsEntry {
node_id: string;
status: string;
sessions: number;
session_resumes_total: number | null;
websocket_dispatches_total: number | null;
websocket_dispatch_drops_total: number | null;
guilds: number;
presences: number;
calls: number;
@@ -74,6 +77,9 @@ export interface GatewayNodeStatsEntry {
export interface GatewayNodeStats {
status: string;
sessions: number;
session_resumes_total: number;
websocket_dispatches_total: number;
websocket_dispatch_drops_total: number;
guilds: number;
presences: number;
calls: number;
@@ -968,6 +968,9 @@ export class NoopGatewayService extends IGatewayService {
return {
status: 'ok',
sessions: 0,
session_resumes_total: 0,
websocket_dispatches_total: 0,
websocket_dispatch_drops_total: 0,
guilds: 0,
presences: 0,
calls: 0,
+3 -3
View File
@@ -23,9 +23,9 @@
<meta name="display" content="standalone">
<link rel="license" href="/assets/fonts-NOTICE.txt">
<!--{{FLUXER_BOOTSTRAP}}-->
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{var loc=window.location;if(loc.pathname==='/'){var target='/channels/@me';if(loc.search)target+=loc.search;if(loc.hash)target+=loc.hash;loc.replace(target);}}catch(e){}})();</script>
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{var t=localStorage.getItem('theme');if(t){document.documentElement.classList.add('theme-'+t)}}catch{}})()</script>
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{if(typeof WebSocket!=='function')return;if(window.location.pathname.indexOf('/migrate/')===0)return;var t=localStorage.getItem('token');if(!t||t==='undefined'||t==='null')return;var b=window.__FLUXER_BOOTSTRAP__;var g=b&&b.instance&&b.instance.endpoints&&b.instance.endpoints.gateway;if(!g)return;var u=new URL(g);u.searchParams.set('v','1');u.searchParams.set('encoding','json');u.searchParams.set('compress','zstd-stream');u.searchParams.set('stream','1');var url=u.toString();var ws=new WebSocket(url);ws.binaryType='arraybuffer';var s={open:false,url:url,messages:[],startedAt:Date.now()};ws.onopen=function(){s.open=true};ws.onmessage=function(e){s.messages.push(e)};ws.onclose=ws.onerror=function(){window.__FLUXER_FAST_CONNECT__=null};window.__FLUXER_FAST_CONNECT__={ws:ws,state:s};setTimeout(function(){var h=window.__FLUXER_FAST_CONNECT__;if(h&&h.ws===ws){window.__FLUXER_FAST_CONNECT__=null;try{ws.close(1000,'Fast connect unclaimed')}catch(e){}}},30000)}catch(e){}})()</script>
<script>(function(){try{var loc=window.location;if(loc.pathname==='/'){var target='/channels/@me';if(loc.search)target+=loc.search;if(loc.hash)target+=loc.hash;loc.replace(target);}}catch(e){}})();</script>
<script>(function(){try{var t=localStorage.getItem('theme');if(t){document.documentElement.classList.add('theme-'+t)}}catch{}})()</script>
<script>(function(){try{if(typeof WebSocket!=='function')return;if(window.location.pathname.indexOf('/migrate/')===0)return;var t=localStorage.getItem('token');if(!t||t==='undefined'||t==='null')return;var b=window.__FLUXER_BOOTSTRAP__;var g=b&&b.instance&&b.instance.endpoints&&b.instance.endpoints.gateway;if(!g)return;var u=new URL(g);u.searchParams.set('v','1');u.searchParams.set('encoding','json');u.searchParams.set('compress','zstd-stream');u.searchParams.set('stream','1');var url=u.toString();var ws=new WebSocket(url);ws.binaryType='arraybuffer';var s={open:false,url:url,messages:[],startedAt:Date.now()};ws.onopen=function(){s.open=true};ws.onmessage=function(e){s.messages.push(e)};ws.onclose=ws.onerror=function(){window.__FLUXER_FAST_CONNECT__=null};window.__FLUXER_FAST_CONNECT__={ws:ws,state:s};setTimeout(function(){var h=window.__FLUXER_FAST_CONNECT__;if(h&&h.ws===ws){window.__FLUXER_FAST_CONNECT__=null;try{ws.close(1000,'Fast connect unclaimed')}catch(e){}}},30000)}catch(e){}})()</script>
</head>
<body>
<div id="root"></div>
@@ -54,6 +54,7 @@ import {
supportsAV1,
supportsDynacast,
supportsH265,
supportsScalabilityMode,
supportsVideoCodec,
supportsVP9,
} from './room/utils.ts';
@@ -176,6 +177,7 @@ export {
supportsAV1,
supportsDynacast,
supportsH265,
supportsScalabilityMode,
supportsVideoCodec,
supportsVP9,
TrackPublication,
@@ -110,6 +110,7 @@ import {
selectPreferredVideoCodec,
sleep,
stopTransceiversForSender,
supportsScalabilityMode,
supportsVideoCodec,
usesLegacySVCEncodings,
} from '../utils.ts';
@@ -978,6 +979,9 @@ export default class LocalParticipant extends Participant {
}
const videoCodec = opts.videoCodec;
if (!supportsScalabilityMode()) {
delete opts.scalabilityMode;
}
track.on(TrackEvent.Muted, this.onTrackMuted);
track.on(TrackEvent.Unmuted, this.onTrackUnmuted);
@@ -1055,7 +1059,12 @@ export default class LocalParticipant extends Participant {
}
const svcSimulcast = isSVCSimulcast(videoCodec, opts);
if (isSVCCodec(videoCodec) && !svcSimulcast && track.source !== Track.Source.ScreenShare) {
if (
isSVCCodec(videoCodec) &&
!svcSimulcast &&
track.source !== Track.Source.ScreenShare &&
supportsScalabilityMode()
) {
opts.scalabilityMode = opts.scalabilityMode ?? 'L3T3_KEY';
}
@@ -66,7 +66,7 @@ export function supportsDynacast() {
}
export function supportsAV1(): boolean {
if (isSafari() || isFireFox()) {
if (isSafari()) {
return false;
}
return hasSenderMimeType('video/av1') || hasSenderMimeType('video/av1x');
@@ -80,9 +80,6 @@ export function supportsH265(): boolean {
}
export function supportsVP9(): boolean {
if (isFireFox()) {
return false;
}
if (isSafari()) {
const browser = getBrowser();
if (browser?.version && compareVersions(browser.version, '16') < 0) {
@@ -133,6 +130,10 @@ function hasSenderMimeType(mimeType: string): boolean {
return capabilities.codecs.some((codec) => codec.mimeType.toLowerCase() === lower);
}
export function supportsScalabilityMode(): boolean {
return !isFireFox();
}
export function isSVCCodec(codec?: string): boolean {
return codec === 'av1' || codec === 'vp9';
}
+1 -1
View File
@@ -160,7 +160,7 @@ async function bootstrapApp(): Promise<void> {
loadLazyModule(() => import('@app/features/auth/state/AccountManager')),
loadLazyModule(() => import('@app/features/channel/state/ChannelDisplayName')),
loadLazyModule(() => import('@app/features/channel/state/ChannelFrecency')),
loadLazyModule(() => import('@app/features/app/state/GeoIP')),
loadLazyModule(() => import('@app/features/app/state/GeoIP')).then(({default: GeoIP}) => GeoIP.load()),
loadLazyModule(() => import('@app/features/input/state/InputKeybind')),
loadLazyModule(() => import('@app/features/auth/state/NewDeviceMonitoring')),
loadLazyModule(() => import('@app/features/ui/state/Notification')),
+1 -5
View File
@@ -2,10 +2,7 @@
import MediaEngineFacade from '@app/features/voice/engine/MediaEngineFacade';
import type {ElectronAPI} from '@app/features/platform/types/Electron';
import type {
GeolocationResponse,
InstanceDiscoveryResponse,
} from '@fluxer/instance_bootstrap/src/Types';
import type {InstanceDiscoveryResponse} from '@fluxer/instance_bootstrap/src/Types';
import {Buffer} from 'buffer';
type MediaEngineInstance = typeof MediaEngineFacade;
@@ -31,7 +28,6 @@ interface FluxerBootstrapGlobal {
bootstrapApiPublicEndpoint?: string;
};
instance: InstanceDiscoveryResponse;
geoip: GeolocationResponse;
}
declare global {
+77 -20
View File
@@ -1,40 +1,97 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {GeoEntry, GeolocationResponse} from '@fluxer/instance_bootstrap/src/Types';
import {Logger} from '@app/features/platform/utils/AppLogger';
import ageGeos from '@fluxer/constants/src/AgeGeos.json';
import type {GeoEntry} from '@fluxer/instance_bootstrap/src/Types';
import {GeolocationResponse} from '@fluxer/schema/src/domains/geolocation/GeolocationSchemas';
import {makeAutoObservable, runInAction} from 'mobx';
const GEOIP_PATH = '/_geoip';
const GEOIP_ATTEMPTS = 3;
const GEOIP_ATTEMPT_TIMEOUT_MS = 2000;
const GEOIP_RETRY_DELAY_MS = 200;
const logger = new Logger('GeoIP');
interface ConnectionGeoCoordinates {
latitude: string | null;
longitude: string | null;
}
function getInlinedGeoip(): GeolocationResponse {
const bootstrap = typeof window !== 'undefined' ? window.__FLUXER_BOOTSTRAP__ : undefined;
if (!bootstrap) {
throw new Error('window.__FLUXER_BOOTSTRAP__ is missing — app must be served by fluxer_app_proxy');
}
return bootstrap.geoip;
const UNRESOLVED_GEOIP: GeolocationResponse = {
countryCode: null,
regionCode: null,
latitude: null,
longitude: null,
ageRestrictedGeos: ageGeos.ageRestrictedGeos,
ageBlockedGeos: ageGeos.ageBlockedGeos,
};
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchGeoipOnce(): Promise<GeolocationResponse> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), GEOIP_ATTEMPT_TIMEOUT_MS);
try {
const response = await fetch(GEOIP_PATH, {
cache: 'no-store',
credentials: 'omit',
headers: {Accept: 'application/json'},
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`GeoIP lookup failed with status ${response.status}`);
}
return GeolocationResponse.parse(await response.json());
} finally {
clearTimeout(timer);
}
}
async function fetchGeoip(): Promise<GeolocationResponse> {
for (let attempt = 1; ; attempt++) {
try {
return await fetchGeoipOnce();
} catch (error) {
if (attempt >= GEOIP_ATTEMPTS) {
logger.warn('GeoIP lookup failed, continuing without a location:', error);
return UNRESOLVED_GEOIP;
}
await wait(GEOIP_RETRY_DELAY_MS * attempt);
}
}
}
let loading: Promise<void> | null = null;
class GeoIP {
countryCode: string | null;
regionCode: string | null;
latitude: string | null;
longitude: string | null;
ageRestrictedGeos: ReadonlyArray<GeoEntry>;
ageBlockedGeos: ReadonlyArray<GeoEntry>;
countryCode: string | null = UNRESOLVED_GEOIP.countryCode;
regionCode: string | null = UNRESOLVED_GEOIP.regionCode;
latitude: string | null = UNRESOLVED_GEOIP.latitude;
longitude: string | null = UNRESOLVED_GEOIP.longitude;
ageRestrictedGeos: ReadonlyArray<GeoEntry> = UNRESOLVED_GEOIP.ageRestrictedGeos;
ageBlockedGeos: ReadonlyArray<GeoEntry> = UNRESOLVED_GEOIP.ageBlockedGeos;
constructor() {
const data = getInlinedGeoip();
this.countryCode = data.countryCode;
this.regionCode = data.regionCode;
this.latitude = data.latitude;
this.longitude = data.longitude;
this.ageRestrictedGeos = data.ageRestrictedGeos;
this.ageBlockedGeos = data.ageBlockedGeos;
makeAutoObservable(this, {}, {autoBind: true});
}
load(): Promise<void> {
loading ??= fetchGeoip().then((data) => {
runInAction(() => {
this.countryCode = data.countryCode;
this.regionCode = data.regionCode;
this.latitude = data.latitude;
this.longitude = data.longitude;
this.ageRestrictedGeos = data.ageRestrictedGeos;
this.ageBlockedGeos = data.ageBlockedGeos;
});
});
return loading;
}
applyConnectionFallbackCoordinates(data: ConnectionGeoCoordinates): void {
if (data.latitude === null || data.longitude === null) {
return;
@@ -30220,8 +30220,8 @@ msgstr "تم تعطيل إرسال الرسائل في هذا المجتمع"
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "يرسل كلا قناتي ميكروفون ستيريو. يتطلب إيقاف تشغيل كل المعالجة وقناة بسرعة 128 كيلوبت في الثانية أو أعلى."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "يرسل كلا قناتي ميكروفون ستيريو. يتطلب إيقاف تشغيل كل المعالجة وقناة بمعدل 64 كيلوبت في الثانية أو أعلى."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -2985,6 +2985,9 @@
{
"msgid": "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
},
{
"msgid": "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
},
{
"msgid": "Sends both channels of a stereo microphone. Works only with no suppression or standard suppression."
},
@@ -30220,8 +30220,8 @@ msgstr "Изпращането на съобщения е деактивиран
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "Изпраща и двата канала на стерео микрофон. Изисква всички обработки да са изключени и каналът да е с 128 kbps или повече."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "Изпраща и двата канала на стерео микрофон. Изисква всички обработки да са изключени и каналът да е с 64 kbps или повече."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "V této komunitě je odesílání zpráv zakázáno"
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "Odesílá oba kanály stereofonního mikrofonu. Vyžaduje vypnuté veškeré zpracování a kanál s rychlostí 128 kb/s nebo vyšší."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "Odesílá oba kanály stereofonního mikrofonu. Vyžaduje vypnutí veškerého zpracování a kanál s rychlostí 64 kb/s nebo vyšší."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "Afsendelse af beskeder er deaktiveret i dette fællesskab"
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "Sender begge kanaler fra en stereomikrofon. Kræver, at al behandling er slået fra og en kanal på 128 kbps eller højere."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "Sender begge kanaler fra en stereomikrofon. Kræver, at al behandling er slået fra og en kanal på 64 kbps eller højere."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "Das Senden von Nachrichten ist in dieser Community deaktiviert"
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "Sendet beide Kanäle eines Stereo-Mikrofons. Benötigt alle Verarbeitungen aus und einen Kanal mit 128 KBit/s oder höher."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "Sendet beide Kanäle eines Stereomikrofons. Benötigt deaktivierte Verarbeitung und einen Kanal mit 64 KBit/s oder höher."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "Η αποστολή μηνυμάτων έχει απενεργοποιη
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "Στέλνει και τα δύο κανάλια ενός στερεοφωνικού μικροφώνου. Απαιτείται απενεργοποίηση όλων των επεξεργασιών και κανάλι στα 128 kbps ή υψηλότερο."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "Στέλνει και τα δύο κανάλια ενός στερεοφωνικού μικροφώνου. Απαιτεί απενεργοποίηση όλης της επεξεργασίας και κανάλι στα 64 kbps ή υψηλότερο."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -20011,7 +20011,6 @@ msgstr "Linked devices"
msgid "Linked file unavailable"
msgstr "Linked file unavailable"
# auto-i18n: reviewed unchanged
#. Banner above a linked theme in the Theme Studio library while the app is still checking its CSS file, or when this version of the desktop app cannot follow file changes. {path} is the full path of the CSS file on disk.
#: src/features/theme_studio/sections/LibrarySection.tsx:161
msgid "Linked to {path}."
@@ -30219,10 +30218,11 @@ msgstr "Sending malware or dangerous links"
msgid "Sending messages is disabled in this community"
msgstr "Sending messages is disabled in this community"
# auto-i18n: reviewed unchanged
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "Sending messages is disabled in this community"
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "No se pueden enviar mensajes en esta comunidad"
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "Envía ambos canales de un micrófono estéreo. Requiere que todo el procesamiento esté desactivado y un canal a 128 kbps o superior."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "Envía ambos canales de un micrófono estéreo. Requiere que todo el procesamiento esté desactivado y un canal a 64 kbps o superior."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "El envío de mensajes está deshabilitado en esta comunidad"
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "Envía ambos canales de un micrófono estéreo. Requiere que todo el procesamiento esté desactivado y un canal a 128 kbps o superior."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "Envía ambos canales de un micrófono estéreo. Requiere que todo el procesamiento esté desactivado y un canal a 64 kbps o superior."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "Viestien lähettäminen on poistettu käytöstä tässä yhteisössä"
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "Lähettää stereomikrofonin molemmat kanavat. Vaatii kaikkien käsittelyjen poiskytkemisen ja kanavan vähintään 128 kbps nopeudella."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "Lähettää stereomikrofonin molemmat kanavat. Vaatii kaikkien käsittelyjen poiskytkemisen ja kanavan vähintään 64 kbps nopeudella."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "L'envoi de messages est désactivé dans cette communauté"
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "Envoie les deux canaux d'un microphone stéréo. Nécessite que tout le traitement soit désactivé et un canal à 128 kbps ou plus."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "Envoie les deux canaux d'un microphone stéréo. Nécessite que tout le traitement soit désactivé et un canal à 64 kbit/s ou plus."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "שליחת הודעות מושבתת בקהילה זו"
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "שולח את שני הערוצים של מיקרופון סטריאו. דורש שכל העיבוד כבוי וערוץ במהירות 128 קילו-ביט לשנייה או יותר."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "שולח את שני הערוצים של מיקרופון סטריאו. דורש שכל העיבוד כבוי וערוץ במהירות 64 קילו-ביט לשנייה או יותר."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "इस कम्युनिटी में मैसेज भेज
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "स्टीरियो माइक्रोफ़ोन के दोनों चैनल भेजता है। इसके लिए सभी प्रोसेसिंग बंद और 128 kbps या उससे अधिक का चैनल चाहिए।"
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "स्टीरियो माइक्रोफ़ोन के दोनों चैनल भेजता है। इसके लिए सभी प्रोसेसिंग बंद होनी चाहिए और चैनल 64 kbps या उससे अधिक का होना चाहिए।"
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "Slanje poruka onemogućeno je u ovoj zajednici"
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "Šalje oba kanala stereo mikrofona. Potrebno je isključiti svu obradu i kanal od 128 kbps ili više."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "Šalje oba kanala stereo mikrofona. Potrebno je isključiti svu obradu i kanal od 64 kbps ili više."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "Az üzenetküldés le van tiltva ebben a közösségben"
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "Mindkét sztereó mikrofoncsatornát küldi. Minden feldolgozás kikapcsolása és legalább 128 kbps csatorna szükséges."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "Mindkét sztereó mikrofoncsatornát küldi. Minden feldolgozás kikapcsolása és legalább 64 kbps csatorna szükséges."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "Pengiriman pesan dinonaktifkan di komunitas ini"
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "Mengirimkan kedua saluran mikrofon stereo. Membutuhkan semua pemrosesan nonaktif dan saluran pada 128 kbps atau lebih tinggi."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "Mengirimkan kedua saluran mikrofon stereo. Membutuhkan semua pemrosesan dinonaktifkan dan saluran pada 64 kbps atau lebih tinggi."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "L'invio di messaggi è disabilitato in questa community"
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "Invia entrambi i canali di un microfono stereo. Richiede l'elaborazione disattivata e un canale a 128 kbps o superiore."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "Invia entrambi i canali di un microfono stereo. Richiede l'elaborazione disattivata e un canale a 64 kbps o superiore."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "このコミュニティではメッセージを送信できません"
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "ステレオマイクの全チャンネルを送信します。すべての処理をオフにし、チャンネルを128kbps以上に設定する必要があります。"
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "ステレオマイクの両方のチャンネルを送信します。すべての処理をオフにし、チャンネルを 64 kbps 以上にする必要があります。"
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "이 커뮤니티에서는 메시지 전송이 비활성화되어 있습
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "스테레오 마이크의 두 채널을 모두 보냅니다. 모든 처리를 끄고 채널당 128kbps 이상이 필요합니다."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "스테레오 마이크의 두 채널을 모두 보냅니다. 모든 처리를 끄고 채널당 64kbps 이상이 필요합니다."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30221,8 +30221,8 @@ msgstr "Šioje bendruomenėje žinučių siuntimas išjungtas"
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "Siunčia abu stereo mikrofono kanalus. Reikia išjungti visą apdorojimą ir naudoti 128 kbps ar didesnį kanalo bitų dažnį."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "Siunčia abu stereo mikrofono kanalus. Reikia išjungti visą apdorojimą ir nustatyti 64 kbps ar didesnį kanalo bitų dažnį."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "Berichten versturen is uitgeschakeld in deze community"
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "Verstuurt beide kanalen van een stereomicrofoon. Vereist dat alle verwerking is uitgeschakeld en een kanaal van 128 kbps of hoger."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "Verstuurt beide kanalen van een stereomicrofoon. Vereist dat alle verwerking is uitgeschakeld en een kanaal van 64 kbps of hoger."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "Sending av meldinger er deaktivert i dette fellesskapet"
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "Sender begge kanalene fra en stereomikrofon. Krever at all prosessering er av og en kanal på 128 kbps eller høyere."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "Sender begge kanalene fra en stereomikrofon. Krever at all prosessering er av og en kanal på 64 kbps eller høyere."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "Wysyłanie wiadomości jest wyłączone w tej społeczności"
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "Wysyła oba kanały mikrofonu stereo. Wymaga wyłączenia wszystkich procesów i kanału o przepustowości 128 kb/s lub wyższej."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "Wysyła oba kanały mikrofonu stereo. Wymaga wyłączenia wszystkich procesów i kanału o przepustowości 64 kb/s lub wyższej."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "O envio de mensagens está desativado nesta comunidade"
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "Envia ambos os canais de um microfone estéreo. Requer que todo o processamento esteja desativado e um canal a 128 kbps ou superior."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "Envia ambos os canais de um microfone estéreo. Requer todo o processamento desativado e um canal a 64 kbps ou superior."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "Trimiterea mesajelor este dezactivată în această comunitate"
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "Trimite ambele canale ale unui microfon stereo. Necesită dezactivarea tuturor procesărilor și un canal la 128 kbps sau mai mare."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "Trimite ambele canale ale unui microfon stereo. Necesită dezactivarea tuturor procesărilor și un canal de 64 kbps sau mai mare."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "Отправка сообщений в этом сообществе о
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "Отправляет оба канала стереомикрофона. Требуется отключить всю обработку и установить скорость канала 128 Кбит/с или выше."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "Отправляет оба канала стереомикрофона. Требует отключения всей обработки и скорости канала 64 Кбит/с или выше."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "Det går inte att skicka meddelanden i den här communityn"
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "Skickar båda kanalerna från en stereomikrofon. Kräver att all bearbetning är avstängd och en kanal på 128 kbps eller högre."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "Skickar båda kanalerna från en stereomikrofon. Kräver att all bearbetning är avstängd och en kanal på minst 64 kbps."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "การส่งข้อความในคอมมูนิตี
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "ส่งทั้งสองช่องสัญญาณของไมโครโฟนสเตอริโอ ต้องปิดการประมวลผลทั้งหมดและตั้งค่าช่องสัญญาณที่ 128 kbps ขึ้นไป"
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "ส่งทั้งสองช่องสัญญาณของไมโครโฟนสเตอริโอ ต้องปิดการประมวลผลทั้งหมดและตั้งค่าช่องสัญญาณที่ 64 kbps ขึ้นไป"
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "Bu toplulukta mesaj gönderme devre dışı bırakıldı"
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "Stereo mikrofonunuzun her iki kanalını da gönderir. Tüm işlemelerin kapalı olmasını ve kanalın 128 kbps veya daha yüksek olmasını gerektirir."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "Stereo mikrofonunuzun her iki kanalını da gönderir. Tüm işlemelerin kapalı olmasını ve kanalın 64 kbps veya daha yüksek olmasını gerektirir."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "Надсилання повідомлень у цій спільноті
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "Надсилає обидва канали стереомікрофона. Потребує вимкнення всієї обробки та каналу зі швидкістю 128 Кбіт/с або вище."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "Надсилає обидва канали стереомікрофона. Потребує вимкнення всієї обробки та каналу зі швидкістю 64 Кбіт/с або вище."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "Đã tắt tính năng gửi tin nhắn trong cộng đồng này"
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "Gửi cả hai kênh của micrô stereo. Yêu cầu tắt mọi xử lý và kênh có tốc độ 128 kbps trở lên."
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "Gửi cả hai kênh của micrô stereo. Cần tắt mọi xử lý và kênh có tốc độ 64 kbps trở lên."
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "此社区已禁用发送消息"
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "发送立体声麦克风的两个声道。需要关闭所有处理,并将声道设置为 128 kbps 或更高。"
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "发送立体声麦克风的两个声道。需要关闭所有处理,并且声道码率为 64 kbps 或更高。"
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -30220,8 +30220,8 @@ msgstr "此社群已停用訊息傳送功能"
#. Description for the stereo microphone switch in the voice settings.
#: src/features/voice/utils/noise_suppression/NoiseSuppressionLabels.ts:54
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
msgstr "傳送立體聲麥克風的兩個聲道。需要關閉所有處理,且聲道速率為 128 kbps 或更高。"
msgid "Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher."
msgstr "傳送立體聲麥克風的兩個聲道。需要關閉所有處理,且頻道速率為 64 kbps 或更高。"
#. Description for the studio / direct-input option in the voice processing settings radio group.
#: src/features/voice/components/voice_connection_status/AudioProcessingModal.tsx:38
@@ -19,14 +19,14 @@ const UNRENDERED_INDEX_TEMPLATE = [
'<!doctype html><html lang="en"><head>',
'<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}">',
'<link rel="apple-touch-icon" href="{{STATIC_CDN_ENDPOINT}}/web/apple-touch-icon.png">',
'<script nonce="{{CSP_NONCE_PLACEHOLDER}}"></script>',
'<script></script>',
'</head><body><div id="root"></div></body></html>',
].join('');
const RENDERED_INDEX_DOCUMENT = [
'<!doctype html><html lang="en"><head>',
'<link rel="preconnect" href="https://cdn.fluxer.test">',
'<script nonce="abc123">window.__FLUXER_BOOTSTRAP__={"instance":{}};</script>',
'<script>window.__FLUXER_BOOTSTRAP__={"instance":{}};</script>',
'</head><body><div id="root"></div></body></html>',
].join('');
@@ -11,8 +11,10 @@
isolation: isolate;
container-name: plutonium-page;
container-type: size;
background-color: #0a0822;
color: #fff;
--pl-fg: 255 255 255;
--pl-bg: #080811;
background-color: var(--pl-bg);
color: rgb(var(--pl-fg));
font-family: 'Radio Canada Big', var(--font-sans);
-webkit-font-smoothing: auto;
-moz-osx-font-smoothing: auto;
@@ -21,9 +23,27 @@
.scroller {
--brand-solid: #4641d9;
--brand-primary-light: #b0adff;
--stars-far: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='577'%20height='577'%3E%3Ccircle%20cx='186.9'%20cy='87.0'%20r='0.71'%20fill='rgb(255,255,255)'%20fill-opacity='0.24'/%3E%3Ccircle%20cx='54.3'%20cy='336.3'%20r='0.81'%20fill='rgb(255,255,255)'%20fill-opacity='0.29'/%3E%3Ccircle%20cx='250.2'%20cy='40.3'%20r='0.49'%20fill='rgb(255,255,255)'%20fill-opacity='0.36'/%3E%3Ccircle%20cx='71.4'%20cy='128.8'%20r='0.70'%20fill='rgb(255,255,255)'%20fill-opacity='0.53'/%3E%3Ccircle%20cx='337.9'%20cy='28.6'%20r='0.54'%20fill='rgb(255,255,255)'%20fill-opacity='0.40'/%3E%3Ccircle%20cx='167.1'%20cy='83.2'%20r='0.50'%20fill='rgb(255,255,255)'%20fill-opacity='0.32'/%3E%3Ccircle%20cx='104.3'%20cy='335.6'%20r='0.71'%20fill='rgb(255,255,255)'%20fill-opacity='0.34'/%3E%3Ccircle%20cx='410.9'%20cy='325.6'%20r='0.70'%20fill='rgb(255,255,255)'%20fill-opacity='0.38'/%3E%3Ccircle%20cx='246.7'%20cy='181.3'%20r='0.68'%20fill='rgb(255,255,255)'%20fill-opacity='0.37'/%3E%3Ccircle%20cx='143.3'%20cy='103.7'%20r='0.76'%20fill='rgb(255,255,255)'%20fill-opacity='0.25'/%3E%3Ccircle%20cx='303.0'%20cy='505.0'%20r='0.74'%20fill='rgb(255,255,255)'%20fill-opacity='0.32'/%3E%3Ccircle%20cx='68.1'%20cy='241.3'%20r='0.75'%20fill='rgb(255,255,255)'%20fill-opacity='0.27'/%3E%3Ccircle%20cx='243.3'%20cy='555.1'%20r='0.48'%20fill='rgb(255,255,255)'%20fill-opacity='0.40'/%3E%3Ccircle%20cx='196.3'%20cy='202.1'%20r='0.65'%20fill='rgb(255,255,255)'%20fill-opacity='0.48'/%3E%3Ccircle%20cx='484.7'%20cy='545.1'%20r='0.64'%20fill='rgb(255,255,255)'%20fill-opacity='0.44'/%3E%3Ccircle%20cx='421.9'%20cy='178.6'%20r='0.68'%20fill='rgb(255,255,255)'%20fill-opacity='0.44'/%3E%3Ccircle%20cx='164.2'%20cy='222.6'%20r='0.72'%20fill='rgb(255,255,255)'%20fill-opacity='0.23'/%3E%3Ccircle%20cx='205.1'%20cy='352.5'%20r='0.65'%20fill='rgb(255,255,255)'%20fill-opacity='0.29'/%3E%3Ccircle%20cx='74.6'%20cy='142.9'%20r='0.61'%20fill='rgb(255,255,255)'%20fill-opacity='0.51'/%3E%3Ccircle%20cx='96.0'%20cy='231.7'%20r='0.56'%20fill='rgb(255,255,255)'%20fill-opacity='0.27'/%3E%3Ccircle%20cx='498.5'%20cy='160.6'%20r='0.62'%20fill='rgb(255,255,255)'%20fill-opacity='0.34'/%3E%3Ccircle%20cx='552.6'%20cy='87.1'%20r='0.52'%20fill='rgb(255,255,255)'%20fill-opacity='0.30'/%3E%3Ccircle%20cx='7.0'%20cy='479.5'%20r='0.52'%20fill='rgb(255,255,255)'%20fill-opacity='0.31'/%3E%3Ccircle%20cx='241.7'%20cy='213.1'%20r='0.68'%20fill='rgb(255,255,255)'%20fill-opacity='0.53'/%3E%3Ccircle%20cx='495.8'%20cy='548.3'%20r='0.71'%20fill='rgb(255,255,255)'%20fill-opacity='0.46'/%3E%3Ccircle%20cx='519.0'%20cy='450.0'%20r='0.80'%20fill='rgb(255,255,255)'%20fill-opacity='0.48'/%3E%3Ccircle%20cx='229.7'%20cy='227.4'%20r='0.64'%20fill='rgb(255,255,255)'%20fill-opacity='0.35'/%3E%3Ccircle%20cx='38.9'%20cy='120.5'%20r='0.51'%20fill='rgb(255,255,255)'%20fill-opacity='0.33'/%3E%3Ccircle%20cx='59.1'%20cy='327.0'%20r='0.66'%20fill='rgb(255,255,255)'%20fill-opacity='0.53'/%3E%3Ccircle%20cx='14.7'%20cy='504.5'%20r='0.70'%20fill='rgb(255,255,255)'%20fill-opacity='0.27'/%3E%3Ccircle%20cx='551.3'%20cy='347.5'%20r='0.64'%20fill='rgb(255,255,255)'%20fill-opacity='0.26'/%3E%3Ccircle%20cx='573.0'%20cy='268.9'%20r='0.64'%20fill='rgb(255,255,255)'%20fill-opacity='0.25'/%3E%3Ccircle%20cx='432.6'%20cy='427.2'%20r='0.64'%20fill='rgb(255,255,255)'%20fill-opacity='0.45'/%3E%3Ccircle%20cx='13.3'%20cy='548.7'%20r='0.66'%20fill='rgb(255,255,255)'%20fill-opacity='0.27'/%3E%3Ccircle%20cx='527.5'%20cy='437.4'%20r='0.57'%20fill='rgb(255,255,255)'%20fill-opacity='0.43'/%3E%3Ccircle%20cx='401.7'%20cy='150.7'%20r='0.60'%20fill='rgb(255,255,255)'%20fill-opacity='0.28'/%3E%3Ccircle%20cx='307.3'%20cy='449.5'%20r='0.58'%20fill='rgb(255,255,255)'%20fill-opacity='0.29'/%3E%3Ccircle%20cx='465.1'%20cy='472.2'%20r='0.75'%20fill='rgb(255,255,255)'%20fill-opacity='0.29'/%3E%3C/svg%3E");
--stars-near: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='947'%20height='947'%3E%3Ccircle%20cx='641.2'%20cy='743.3'%20r='3.80'%20fill='rgb(255,255,255)'%20fill-opacity='0.056'/%3E%3Ccircle%20cx='641.2'%20cy='743.3'%20r='1.19'%20fill='rgb(255,255,255)'%20fill-opacity='0.46'/%3E%3Ccircle%20cx='501.2'%20cy='553.7'%20r='3.93'%20fill='rgb(255,255,255)'%20fill-opacity='0.040'/%3E%3Ccircle%20cx='501.2'%20cy='553.7'%20r='1.23'%20fill='rgb(255,255,255)'%20fill-opacity='0.33'/%3E%3Ccircle%20cx='253.7'%20cy='101.9'%20r='1.08'%20fill='rgb(255,255,255)'%20fill-opacity='0.40'/%3E%3Ccircle%20cx='832.6'%20cy='871.6'%20r='3.08'%20fill='rgb(255,255,255)'%20fill-opacity='0.053'/%3E%3Ccircle%20cx='832.6'%20cy='871.6'%20r='0.96'%20fill='rgb(255,255,255)'%20fill-opacity='0.45'/%3E%3Ccircle%20cx='93.3'%20cy='15.9'%20r='1.22'%20fill='rgb(255,255,255)'%20fill-opacity='0.44'/%3E%3Ccircle%20cx='936.8'%20cy='540.5'%20r='1.18'%20fill='rgb(255,255,255)'%20fill-opacity='0.35'/%3E%3Ccircle%20cx='220.5'%20cy='516.4'%20r='4.17'%20fill='rgb(255,255,255)'%20fill-opacity='0.067'/%3E%3Ccircle%20cx='220.5'%20cy='516.4'%20r='1.30'%20fill='rgb(255,255,255)'%20fill-opacity='0.56'/%3E%3Ccircle%20cx='127.5'%20cy='467.2'%20r='1.20'%20fill='rgb(255,255,255)'%20fill-opacity='0.45'/%3E%3Ccircle%20cx='562.6'%20cy='492.6'%20r='1.35'%20fill='rgb(255,255,255)'%20fill-opacity='0.46'/%3E%3Ccircle%20cx='820.2'%20cy='843.3'%20r='1.03'%20fill='rgb(255,255,255)'%20fill-opacity='0.31'/%3E%3Ccircle%20cx='640.0'%20cy='617.9'%20r='1.41'%20fill='rgb(255,255,255)'%20fill-opacity='0.45'/%3E%3Ccircle%20cx='696.4'%20cy='436.6'%20r='3.41'%20fill='rgb(255,255,255)'%20fill-opacity='0.062'/%3E%3Ccircle%20cx='696.4'%20cy='436.6'%20r='1.07'%20fill='rgb(255,255,255)'%20fill-opacity='0.52'/%3E%3Ccircle%20cx='294.8'%20cy='913.9'%20r='1.13'%20fill='rgb(255,255,255)'%20fill-opacity='0.45'/%3E%3Ccircle%20cx='621.7'%20cy='15.3'%20r='3.65'%20fill='rgb(255,255,255)'%20fill-opacity='0.047'/%3E%3Ccircle%20cx='621.7'%20cy='15.3'%20r='1.14'%20fill='rgb(255,255,255)'%20fill-opacity='0.39'/%3E%3C/svg%3E");
--space-gradient: linear-gradient(to bottom, #07061a 0%, #0c0a28 50%, #110d35 100%);
--flux-light: 176 173 255;
--flux-glow-light: 129 124 255;
--flux-glow-indigo: 99 94 233;
--space-panel: 12 12 24;
--space-deep-rgb: 8 8 17;
--pl-panel-shadow: rgb(0 0 0 / 0.8);
--flux-line-dir: 90deg;
--flux-gradient-dir: 100deg;
--flux-gradient-fallback: #4641d9;
--flux-gradient: linear-gradient(
var(--flux-gradient-dir),
#5a55e6 0%,
var(--flux-gradient-fallback) 55%,
#322cb0 100%
);
--flux-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='128' height='128'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 .05 0'/%3E%3C/filter%3E%3Crect width='128' height='128' filter='url(%23n)'/%3E%3C/svg%3E");
--flux-waves: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1200' height='640'%3E%3Cg fill='none' stroke='%23fff' opacity='.075'%3E%3Cpath d='M0 27C40 31 80 34 120 34C160 33 200 27 240 25C280 23 320 22 360 23C400 23 440 28 480 28C520 29 560 27 600 25C640 23 680 19 720 16C760 14 800 11 840 9C880 7 920 3 960 3C1000 3 1040 5 1080 9C1120 13 1160 23 1200 27' stroke-opacity='1'/%3E%3Cpath d='M0 69C40 73 80 75 120 75C160 76 200 72 240 71C280 69 320 67 360 65C400 63 440 60 480 58C520 56 560 55 600 55C640 55 680 59 720 59C760 58 800 56 840 54C880 51 920 44 960 44C1000 43 1040 47 1080 51C1120 55 1160 65 1200 69' stroke-opacity='0.97'/%3E%3Cpath d='M0 106C40 108 80 111 120 113C160 115 200 118 240 117C280 116 320 111 360 106C400 102 440 92 480 89C520 86 560 87 600 88C640 89 680 96 720 97C760 98 800 97 840 96C880 95 920 91 960 91C1000 92 1040 95 1080 97C1120 99 1160 103 1200 106' stroke-opacity='0.87'/%3E%3Cpath d='M0 141C40 142 80 147 120 149C160 152 200 158 240 157C280 156 320 149 360 144C400 140 440 131 480 127C520 124 560 125 600 125C640 126 680 129 720 131C760 133 800 134 840 137C880 139 920 142 960 144C1000 145 1040 145 1080 144C1120 144 1160 141 1200 141' stroke-opacity='0.72'/%3E%3Cpath d='M0 182C40 181 80 185 120 186C160 187 200 189 240 188C280 187 320 183 360 181C400 178 440 175 480 172C520 170 560 167 600 166C640 164 680 162 720 164C760 166 800 173 840 178C880 183 920 191 960 193C1000 195 1040 192 1080 190C1120 188 1160 183 1200 182' stroke-opacity='0.55'/%3E%3Cpath d='M0 228C40 226 80 224 120 222C160 219 200 216 240 215C280 214 320 216 360 217C400 217 440 219 480 218C520 216 560 210 600 208C640 205 680 202 720 204C760 207 800 215 840 220C880 226 920 232 960 235C1000 237 1040 235 1080 233C1120 232 1160 230 1200 228' stroke-opacity='0.38'/%3E%3Cpath d='M0 274C40 271 80 262 120 257C160 252 200 247 240 246C280 245 320 251 360 253C400 255 440 258 480 258C520 258 560 253 600 253C640 252 680 252 720 253C760 255 800 259 840 262C880 264 920 267 960 269C1000 272 1040 275 1080 276C1120 277 1160 277 1200 274' stroke-opacity='0.23'/%3E%3Cpath d='M0 313C40 310 80 299 120 295C160 290 200 286 240 285C280 283 320 286 360 288C400 289 440 291 480 293C520 295 560 298 600 300C640 302 680 305 720 305C760 306 800 302 840 302C880 302 920 302 960 304C1000 306 1040 313 1080 315C1120 316 1160 316 1200 313' stroke-opacity='0.13'/%3E%3Cpath d='M0 345C40 343 80 339 120 336C160 334 200 331 240 329C280 327 320 323 360 323C400 323 440 325 480 329C520 333 560 343 600 347C640 351 680 354 720 354C760 353 800 347 840 345C880 343 920 342 960 343C1000 343 1040 348 1080 348C1120 349 1160 347 1200 345' stroke-opacity='0.1'/%3E%3Cpath d='M0 375C40 375 80 379 120 379C160 378 200 376 240 374C280 371 320 364 360 364C400 363 440 367 480 371C520 375 560 385 600 389C640 393 680 395 720 395C760 396 800 392 840 391C880 389 920 387 960 385C1000 383 1040 380 1080 378C1120 376 1160 375 1200 375' stroke-opacity='0.13'/%3E%3Cpath d='M0 408C40 409 80 416 120 417C160 418 200 417 240 416C280 415 320 411 360 411C400 412 440 415 480 417C520 419 560 423 600 426C640 428 680 431 720 433C760 435 800 438 840 437C880 436 920 431 960 426C1000 422 1040 412 1080 409C1120 406 1160 407 1200 408' stroke-opacity='0.23'/%3E%3Cpath d='M0 445C40 446 80 449 120 451C160 453 200 454 240 457C280 459 320 462 360 464C400 465 440 465 480 464C520 464 560 461 600 461C640 462 680 467 720 469C760 472 800 478 840 477C880 476 920 469 960 464C1000 460 1040 451 1080 447C1120 444 1160 445 1200 445' stroke-opacity='0.38'/%3E%3Cpath d='M0 486C40 484 80 482 120 484C160 486 200 493 240 498C280 503 320 511 360 513C400 515 440 512 480 510C520 508 560 503 600 502C640 501 680 505 720 506C760 507 800 509 840 508C880 507 920 503 960 501C1000 498 1040 495 1080 492C1120 490 1160 487 1200 486' stroke-opacity='0.55'/%3E%3Cpath d='M0 528C40 525 80 522 120 524C160 527 200 535 240 540C280 546 320 552 360 555C400 557 440 555 480 553C520 552 560 550 600 548C640 546 680 544 720 542C760 539 800 536 840 535C880 534 920 536 960 537C1000 537 1040 539 1080 538C1120 536 1160 530 1200 528' stroke-opacity='0.72'/%3E%3Cpath d='M0 573C40 572 80 572 120 573C160 575 200 579 240 582C280 584 320 587 360 589C400 592 440 595 480 596C520 597 560 597 600 594C640 591 680 582 720 577C760 572 800 567 840 566C880 565 920 571 960 573C1000 575 1040 578 1080 578C1120 578 1160 573 1200 573' stroke-opacity='0.87'/%3E%3Cpath d='M0 620C40 622 80 625 120 625C160 626 200 622 240 622C280 622 320 622 360 624C400 626 440 633 480 635C520 636 560 636 600 633C640 630 680 619 720 615C760 610 800 606 840 605C880 603 920 606 960 608C1000 609 1040 611 1080 613C1120 615 1160 618 1200 620' stroke-opacity='0.97'/%3E%3C/g%3E%3C/svg%3E");
--flux-aurora:
radial-gradient(64rem 30rem at 10% -8rem, rgb(var(--flux-glow-light) / 0.2), transparent 70%),
radial-gradient(56rem 32rem at 92% -6rem, rgb(var(--flux-glow-indigo) / 0.3), transparent 72%);
--flux-ink: linear-gradient(to bottom, hsl(242 32% 4%) 0%, hsl(242 32% 6%) 45%, hsl(242 32% 8.5%) 100%);
--pl-gutter: 1.25rem;
--pl-flow: 0.75rem;
--pl-stack: 1.25rem;
@@ -46,18 +66,20 @@
overflow-y: auto;
overscroll-behavior: contain;
isolation: isolate;
background-color: #0a0822;
background-image: var(--stars-far), var(--stars-near), var(--space-gradient);
background-color: var(--pl-bg);
background-image: var(--flux-grain), var(--flux-aurora), var(--flux-waves), var(--flux-ink);
background-size:
577px 577px,
947px 947px,
128px 128px,
100% 100%,
100% 100%,
1200px 640px,
100% 100%;
background-repeat: repeat, repeat, no-repeat;
background-repeat: repeat, no-repeat, no-repeat, repeat, no-repeat;
background-attachment: local;
color: #fff;
color: rgb(var(--pl-fg));
font-size: 1rem;
line-height: 1.5;
scrollbar-color: rgb(255 255 255 / 0.2) transparent;
scrollbar-color: rgb(var(--pl-fg) / 0.2) transparent;
scrollbar-width: thin;
}
@@ -94,6 +116,39 @@
--pl-text-title-letter-spacing: 0;
}
:global(html[dir='rtl']) .scroller {
--flux-line-dir: 270deg;
--flux-gradient-dir: 260deg;
--flux-aurora:
radial-gradient(64rem 30rem at 90% -8rem, rgb(var(--flux-glow-light) / 0.2), transparent 70%),
radial-gradient(56rem 32rem at 8% -6rem, rgb(var(--flux-glow-indigo) / 0.3), transparent 72%);
}
:global(html.theme-light) .root {
--pl-fg: 18 17 34;
--pl-bg: #f6f6fb;
}
:global(html.theme-light) .scroller {
--brand-primary-light: #4641d9;
--space-panel: 255 255 255;
--space-deep-rgb: 255 255 255;
--pl-panel-shadow: rgb(18 17 34 / 0.18);
--flux-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='128' height='128'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .04 0'/%3E%3C/filter%3E%3Crect width='128' height='128' filter='url(%23n)'/%3E%3C/svg%3E");
--flux-waves: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1200' height='640'%3E%3Cg fill='none' stroke='%234641d9' opacity='.075'%3E%3Cpath d='M0 27C40 31 80 34 120 34C160 33 200 27 240 25C280 23 320 22 360 23C400 23 440 28 480 28C520 29 560 27 600 25C640 23 680 19 720 16C760 14 800 11 840 9C880 7 920 3 960 3C1000 3 1040 5 1080 9C1120 13 1160 23 1200 27' stroke-opacity='1'/%3E%3Cpath d='M0 69C40 73 80 75 120 75C160 76 200 72 240 71C280 69 320 67 360 65C400 63 440 60 480 58C520 56 560 55 600 55C640 55 680 59 720 59C760 58 800 56 840 54C880 51 920 44 960 44C1000 43 1040 47 1080 51C1120 55 1160 65 1200 69' stroke-opacity='0.97'/%3E%3Cpath d='M0 106C40 108 80 111 120 113C160 115 200 118 240 117C280 116 320 111 360 106C400 102 440 92 480 89C520 86 560 87 600 88C640 89 680 96 720 97C760 98 800 97 840 96C880 95 920 91 960 91C1000 92 1040 95 1080 97C1120 99 1160 103 1200 106' stroke-opacity='0.87'/%3E%3Cpath d='M0 141C40 142 80 147 120 149C160 152 200 158 240 157C280 156 320 149 360 144C400 140 440 131 480 127C520 124 560 125 600 125C640 126 680 129 720 131C760 133 800 134 840 137C880 139 920 142 960 144C1000 145 1040 145 1080 144C1120 144 1160 141 1200 141' stroke-opacity='0.72'/%3E%3Cpath d='M0 182C40 181 80 185 120 186C160 187 200 189 240 188C280 187 320 183 360 181C400 178 440 175 480 172C520 170 560 167 600 166C640 164 680 162 720 164C760 166 800 173 840 178C880 183 920 191 960 193C1000 195 1040 192 1080 190C1120 188 1160 183 1200 182' stroke-opacity='0.55'/%3E%3Cpath d='M0 228C40 226 80 224 120 222C160 219 200 216 240 215C280 214 320 216 360 217C400 217 440 219 480 218C520 216 560 210 600 208C640 205 680 202 720 204C760 207 800 215 840 220C880 226 920 232 960 235C1000 237 1040 235 1080 233C1120 232 1160 230 1200 228' stroke-opacity='0.38'/%3E%3Cpath d='M0 274C40 271 80 262 120 257C160 252 200 247 240 246C280 245 320 251 360 253C400 255 440 258 480 258C520 258 560 253 600 253C640 252 680 252 720 253C760 255 800 259 840 262C880 264 920 267 960 269C1000 272 1040 275 1080 276C1120 277 1160 277 1200 274' stroke-opacity='0.23'/%3E%3Cpath d='M0 313C40 310 80 299 120 295C160 290 200 286 240 285C280 283 320 286 360 288C400 289 440 291 480 293C520 295 560 298 600 300C640 302 680 305 720 305C760 306 800 302 840 302C880 302 920 302 960 304C1000 306 1040 313 1080 315C1120 316 1160 316 1200 313' stroke-opacity='0.13'/%3E%3Cpath d='M0 345C40 343 80 339 120 336C160 334 200 331 240 329C280 327 320 323 360 323C400 323 440 325 480 329C520 333 560 343 600 347C640 351 680 354 720 354C760 353 800 347 840 345C880 343 920 342 960 343C1000 343 1040 348 1080 348C1120 349 1160 347 1200 345' stroke-opacity='0.1'/%3E%3Cpath d='M0 375C40 375 80 379 120 379C160 378 200 376 240 374C280 371 320 364 360 364C400 363 440 367 480 371C520 375 560 385 600 389C640 393 680 395 720 395C760 396 800 392 840 391C880 389 920 387 960 385C1000 383 1040 380 1080 378C1120 376 1160 375 1200 375' stroke-opacity='0.13'/%3E%3Cpath d='M0 408C40 409 80 416 120 417C160 418 200 417 240 416C280 415 320 411 360 411C400 412 440 415 480 417C520 419 560 423 600 426C640 428 680 431 720 433C760 435 800 438 840 437C880 436 920 431 960 426C1000 422 1040 412 1080 409C1120 406 1160 407 1200 408' stroke-opacity='0.23'/%3E%3Cpath d='M0 445C40 446 80 449 120 451C160 453 200 454 240 457C280 459 320 462 360 464C400 465 440 465 480 464C520 464 560 461 600 461C640 462 680 467 720 469C760 472 800 478 840 477C880 476 920 469 960 464C1000 460 1040 451 1080 447C1120 444 1160 445 1200 445' stroke-opacity='0.38'/%3E%3Cpath d='M0 486C40 484 80 482 120 484C160 486 200 493 240 498C280 503 320 511 360 513C400 515 440 512 480 510C520 508 560 503 600 502C640 501 680 505 720 506C760 507 800 509 840 508C880 507 920 503 960 501C1000 498 1040 495 1080 492C1120 490 1160 487 1200 486' stroke-opacity='0.55'/%3E%3Cpath d='M0 528C40 525 80 522 120 524C160 527 200 535 240 540C280 546 320 552 360 555C400 557 440 555 480 553C520 552 560 550 600 548C640 546 680 544 720 542C760 539 800 536 840 535C880 534 920 536 960 537C1000 537 1040 539 1080 538C1120 536 1160 530 1200 528' stroke-opacity='0.72'/%3E%3Cpath d='M0 573C40 572 80 572 120 573C160 575 200 579 240 582C280 584 320 587 360 589C400 592 440 595 480 596C520 597 560 597 600 594C640 591 680 582 720 577C760 572 800 567 840 566C880 565 920 571 960 573C1000 575 1040 578 1080 578C1120 578 1160 573 1200 573' stroke-opacity='0.87'/%3E%3Cpath d='M0 620C40 622 80 625 120 625C160 626 200 622 240 622C280 622 320 622 360 624C400 626 440 633 480 635C520 636 560 636 600 633C640 630 680 619 720 615C760 610 800 606 840 605C880 603 920 606 960 608C1000 609 1040 611 1080 613C1120 615 1160 618 1200 620' stroke-opacity='0.97'/%3E%3C/g%3E%3C/svg%3E");
--flux-aurora:
radial-gradient(64rem 30rem at 10% -8rem, rgb(var(--flux-glow-light) / 0.16), transparent 70%),
radial-gradient(56rem 32rem at 92% -6rem, rgb(var(--flux-glow-indigo) / 0.18), transparent 72%);
--flux-ink: linear-gradient(to bottom, hsl(242 60% 98.5%) 0%, hsl(242 50% 96.5%) 45%, hsl(242 45% 94%) 100%);
scrollbar-color: rgb(var(--pl-fg) / 0.25) transparent;
}
:global(html.theme-light[dir='rtl']) .scroller {
--flux-aurora:
radial-gradient(64rem 30rem at 90% -8rem, rgb(var(--flux-glow-light) / 0.16), transparent 70%),
radial-gradient(56rem 32rem at 8% -6rem, rgb(var(--flux-glow-indigo) / 0.18), transparent 72%);
}
:global(:root:lang(ja)) .scroller {
line-break: strict;
}
@@ -109,10 +164,14 @@
@supports (animation-timeline: scroll()) {
@media (prefers-reduced-motion: no-preference) and (pointer: fine) {
.scroller {
background-image: var(--space-gradient);
background-size: 100% 100%;
background-repeat: no-repeat;
scroll-timeline: --plutonium-stars block;
background-image: var(--flux-grain), var(--flux-aurora), var(--flux-ink);
background-size:
128px 128px,
100% 100%,
100% 100%,
100% 100%;
background-repeat: repeat, no-repeat, no-repeat, no-repeat;
scroll-timeline: --plutonium-waves block;
}
.starfield {
@@ -126,56 +185,33 @@
pointer-events: none;
}
.starfield::before,
.starfield::after {
.starfield::before {
position: absolute;
inset-inline: 0;
top: 0;
height: calc(100cqh + 640px);
background-image: var(--flux-waves);
background-size: 1200px 640px;
background-repeat: repeat;
animation-name: plutonium-flux-parallax;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --plutonium-stars;
animation-iteration-count: 12;
animation-range: 0px 23040px;
animation-timeline: --plutonium-waves;
will-change: transform;
content: '';
}
.starfield::before {
height: calc(100cqh + 577px);
background-image: var(--stars-far);
background-size: 577px 577px;
animation-name: plutonium-star-parallax-far;
animation-iteration-count: 8;
animation-range: 0px 23080px;
}
.starfield::after {
height: calc(100cqh + 947px);
background-image: var(--stars-near);
background-size: 947px 947px;
animation-name: plutonium-star-parallax-near;
animation-iteration-count: 10;
animation-range: 0px 21044px;
}
}
}
@keyframes plutonium-star-parallax-far {
@keyframes plutonium-flux-parallax {
from {
transform: translate3d(0, 0, 0);
}
to {
transform: translate3d(0, -577px, 0);
}
}
@keyframes plutonium-star-parallax-near {
from {
transform: translate3d(0, 0, 0);
}
to {
transform: translate3d(0, -947px, 0);
transform: translate3d(0, -640px, 0);
}
}
@@ -198,12 +234,12 @@
border: 0;
border-radius: 999px;
background: transparent;
color: #fff;
color: rgb(var(--pl-fg));
cursor: pointer;
}
.backButton:hover {
background-color: rgb(255 255 255 / 0.08);
background-color: rgb(var(--pl-fg) / 0.08);
}
.backIcon {
@@ -216,9 +252,36 @@
}
.hero {
position: relative;
isolation: isolate;
padding: var(--pl-pagetop) var(--pl-gutter) var(--pl-group);
}
.hero::before {
position: absolute;
inset-inline: 0;
inset-block: 0 -6rem;
z-index: -1;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1440 760' preserveAspectRatio='none'%3E%3Cdefs%3E%3ClinearGradient id='g'%3E%3Cstop offset='0' stop-color='%23b0adff' stop-opacity='0'/%3E%3Cstop offset='.25' stop-color='%23b0adff'/%3E%3Cstop offset='.6' stop-color='%23635ee9'/%3E%3Cstop offset='1' stop-color='%234641d9' stop-opacity='.25'/%3E%3C/linearGradient%3E%3Cpath id='a' d='M0 623.9068531556543C48 626 96 632 144 630C192 627 240 621 288 608C336 595 384 574 432 551C480 528 528 497 576 470C624 442 672 411 720 386C768 362 816 340 864 325C912 310 960 302 1008 298C1056 294 1104 298 1152 301C1200 304 1248 312 1296 315C1344 317 1392 314 1440 314'/%3E%3Cpath id='b' d='M0 693.9068531556543C48 696 96 703 144 701C192 698 240 693 288 680C336 667 384 647 432 624C480 601 528 571 576 544C624 517 672 485 720 461C768 438 816 415 864 401C912 387 960 379 1008 375C1056 372 1104 376 1152 379C1200 382 1248 391 1296 394C1344 396 1392 394 1440 394'/%3E%3C/defs%3E%3Cg fill='none' stroke='url(%23g)' stroke-linecap='round'%3E%3Cuse href='%23a' stroke-width='120' stroke-opacity='.07'/%3E%3Cuse href='%23b' stroke-width='120' stroke-opacity='.07'/%3E%3Cuse href='%23a' stroke-width='36' stroke-opacity='.12'/%3E%3Cuse href='%23b' stroke-width='36' stroke-opacity='.12'/%3E%3Cuse href='%23a' stroke-width='2' stroke-opacity='.75'/%3E%3Cuse href='%23b' stroke-width='2' stroke-opacity='.75'/%3E%3C/g%3E%3C/svg%3E");
background-size: 100% min(100%, 46rem);
background-position: bottom;
background-repeat: no-repeat;
pointer-events: none;
mask-image: linear-gradient(to bottom, transparent 0, transparent 82%, #000 96%);
content: '';
}
:global(html[dir='rtl']) .hero::before {
transform: scaleX(-1);
}
@container plutonium-page (max-width: 48rem) {
.hero::before {
background-size: max(100%, 64rem) min(100%, 46rem);
background-position: bottom right;
}
}
@container plutonium-page (min-width: 48rem) {
.hero {
padding-bottom: 4rem;
@@ -253,7 +316,7 @@
.displayHeading {
margin: 0;
color: #fff;
color: rgb(var(--pl-fg));
font-size: var(--pl-text-display);
font-weight: 700;
line-height: var(--pl-text-display-line-height);
@@ -298,7 +361,7 @@
.heroLead {
max-width: 42rem;
margin: var(--pl-stack) auto 0;
color: rgb(255 255 255 / 0.85);
color: rgb(var(--pl-fg) / 0.85);
font-size: var(--pl-text-lead);
line-height: 1.55;
text-wrap: pretty;
@@ -314,14 +377,14 @@
}
.price {
color: #fff;
color: rgb(var(--pl-fg));
font-size: var(--pl-text-heading);
font-weight: 700;
line-height: 1.25;
}
.priceOr {
color: rgb(255 255 255 / 0.6);
color: rgb(var(--pl-fg) / 0.8);
font-size: 1rem;
line-height: 1.6;
}
@@ -380,18 +443,39 @@
}
.buttonPrimary {
background-color: #fff;
color: var(--brand-solid);
border: 1px solid rgb(var(--flux-light) / 0.35);
border-radius: 0.625rem;
background-color: var(--flux-gradient-fallback);
background-image: var(--flux-gradient);
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.18),
0 12px 32px -16px rgb(var(--flux-glow-light) / 0.6);
color: #fff;
transition:
filter 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease,
opacity 0.15s ease;
}
.buttonPrimary:not(:disabled):hover {
border-color: rgb(var(--flux-light) / 0.7);
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.22),
0 14px 36px -14px rgb(var(--flux-glow-light) / 0.8);
filter: brightness(1.06);
}
.buttonSecondary {
border: 1px solid rgb(255 255 255 / 0.38);
background-color: #24233e;
color: #fff;
border: 1px solid rgb(var(--pl-fg) / 0.4);
border-radius: 0.625rem;
background-color: rgb(var(--space-deep-rgb) / 0.4);
color: rgb(var(--pl-fg));
}
.buttonSecondary:not(:disabled):hover {
background-color: #2e2d4b;
border-color: rgb(var(--pl-fg) / 0.5);
background-color: rgb(var(--pl-fg) / 0.06);
}
.buttonIcon {
@@ -409,8 +493,8 @@
align-items: center;
padding: 0.125rem 0.5rem;
border-radius: 999px;
background-color: rgb(70 65 217 / 0.12);
color: var(--brand-solid);
background-color: rgb(255 255 255 / 0.16);
color: #fff;
font-size: 0.8125rem;
font-weight: 700;
line-height: 1.45;
@@ -450,7 +534,7 @@
padding: 0;
border: 0;
background: none;
color: rgb(255 255 255 / 0.85);
color: rgb(var(--pl-fg) / 0.85);
font-family: inherit;
font-size: 0.9375rem;
font-weight: 600;
@@ -460,7 +544,7 @@
}
.textButton:hover {
color: #fff;
color: rgb(var(--pl-fg));
}
.textButtonIcon {
@@ -472,7 +556,7 @@
.meta {
max-width: var(--pl-measure);
margin: var(--pl-stack) auto 0;
color: rgb(255 255 255 / 0.65);
color: rgb(var(--pl-fg) / 0.65);
font-size: 0.9375rem;
line-height: 1.5;
text-wrap: pretty;
@@ -481,7 +565,7 @@
.notice {
max-width: var(--pl-measure);
margin: var(--pl-stack) auto 0;
color: rgb(255 255 255 / 0.85);
color: rgb(var(--pl-fg) / 0.85);
font-size: 0.9375rem;
font-weight: 600;
line-height: 1.5;
@@ -496,7 +580,7 @@
color: inherit;
font: inherit;
text-decoration-line: underline;
text-decoration-color: rgb(255 255 255 / 0.4);
text-decoration-color: rgb(var(--pl-fg) / 0.4);
text-underline-offset: 4px;
cursor: pointer;
transition:
@@ -505,8 +589,8 @@
}
.inlineLink:hover {
color: #fff;
text-decoration-color: #fff;
color: rgb(var(--pl-fg));
text-decoration-color: rgb(var(--pl-fg));
}
.inlineLink::after,
@@ -533,15 +617,31 @@
}
.glassPanel {
position: relative;
width: 100%;
max-width: 80rem;
box-sizing: border-box;
margin-inline: auto;
border: 1px solid rgb(255 255 255 / 0.1);
border-radius: 1.75rem;
background-color: rgb(255 255 255 / 0.03);
background-image: linear-gradient(to bottom, rgb(255 255 255 / 0.05), rgb(255 255 255 / 0.012));
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.06);
border: 1px solid rgb(var(--pl-fg) / 0.09);
border-radius: 1.125rem;
background-color: rgb(var(--space-panel) / 0.5);
box-shadow: 0 24px 48px -32px var(--pl-panel-shadow);
}
.glassPanel::before {
position: absolute;
inset-block-start: -1px;
inset-inline: 1.5rem;
height: 1px;
background-image: linear-gradient(
var(--flux-line-dir),
transparent,
rgb(var(--flux-light) / 0.7) 20%,
rgb(129 124 255 / 0.7) 70%,
transparent
);
pointer-events: none;
content: '';
}
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
@@ -561,9 +661,47 @@
}
.glassPanelBrand {
border-color: rgb(167 159 251 / 0.3);
background-color: rgb(70 65 217 / 0.14);
background-image: linear-gradient(to bottom, rgb(99 94 233 / 0.34), rgb(70 65 217 / 0.08));
border-color: rgb(var(--flux-light) / 0.2);
background-color: rgb(var(--space-panel));
background-image:
var(--flux-grain),
radial-gradient(
46rem 22rem at 0% 0%,
rgb(var(--flux-glow-indigo) / 0.2),
rgb(var(--flux-glow-indigo) / 0.08) 40%,
transparent 70%
),
radial-gradient(
52rem 26rem at 100% 100%,
rgb(var(--flux-glow-light) / 0.18),
rgb(var(--flux-glow-light) / 0.07) 40%,
transparent 70%
),
var(--flux-waves);
background-size:
128px 128px,
100% 100%,
100% 100%,
1200px 640px;
background-repeat: repeat, no-repeat, no-repeat, repeat;
}
:global(html[dir='rtl']) .glassPanelBrand {
background-image:
var(--flux-grain),
radial-gradient(
46rem 22rem at 100% 0%,
rgb(var(--flux-glow-indigo) / 0.2),
rgb(var(--flux-glow-indigo) / 0.08) 40%,
transparent 70%
),
radial-gradient(
52rem 26rem at 0% 100%,
rgb(var(--flux-glow-light) / 0.18),
rgb(var(--flux-glow-light) / 0.07) 40%,
transparent 70%
),
var(--flux-waves);
}
.managePanel {
@@ -579,7 +717,7 @@
.manageHeading {
margin: 0 0 var(--pl-stack);
color: #fff;
color: rgb(var(--pl-fg));
font-size: var(--pl-text-heading);
font-weight: 700;
line-height: 1.25;
@@ -607,17 +745,34 @@
}
.perkRow {
position: relative;
display: grid;
width: 100%;
max-width: 80rem;
box-sizing: border-box;
align-items: center;
gap: 2rem;
padding: 1.25rem;
margin-inline: auto;
padding-block: 2.5rem;
border-block-start: 1px solid rgb(var(--pl-fg) / 0.08);
}
.perkRow::before {
position: absolute;
inset-block-start: -1px;
inset-inline-start: 0;
inline-size: min(14rem, 45%);
block-size: 1px;
background-image: linear-gradient(var(--flux-line-dir), rgb(var(--flux-light) / 0.9), transparent);
pointer-events: none;
content: '';
}
@container plutonium-page (min-width: 48rem) {
.perkRow {
grid-template-columns: 7fr 5fr;
gap: 4rem;
padding: 3rem;
padding-block: 4rem;
}
.perkRowFlipped {
@@ -647,7 +802,7 @@
.perkTitle {
margin: 0;
color: #fff;
color: rgb(var(--pl-fg));
font-size: var(--pl-text-heading);
font-weight: 700;
line-height: 1.25;
@@ -657,7 +812,7 @@
.perkBody {
margin: var(--pl-stack) 0 0;
color: rgb(255 255 255 / 0.8);
color: rgb(var(--pl-fg) / 0.8);
font-size: var(--pl-text-lead);
line-height: 1.55;
text-wrap: pretty;
@@ -673,7 +828,7 @@
}
.footnoteMarker:hover {
color: #fff;
color: rgb(var(--pl-fg));
}
.perkArt {
@@ -724,7 +879,7 @@
.titleHeading {
margin: 0;
color: #fff;
color: rgb(var(--pl-fg));
font-size: var(--pl-text-title);
font-weight: 700;
line-height: var(--pl-text-title-line-height);
@@ -751,9 +906,9 @@
.perkCard {
padding: 0.875rem;
border: 1px solid rgb(255 255 255 / 0.08);
border: 1px solid rgb(var(--pl-fg) / 0.08);
border-radius: 1rem;
background-color: rgb(255 255 255 / 0.02);
background-color: rgb(var(--pl-fg) / 0.02);
}
.perkCardHead {
@@ -772,7 +927,7 @@
.perkCardLabel {
min-width: 0;
color: #fff;
color: rgb(var(--pl-fg));
font-size: 1rem;
font-weight: 600;
line-height: 1.6;
@@ -790,17 +945,18 @@
flex-direction: column;
gap: 0.125rem;
padding: 0.5rem 0.75rem;
border: 1px solid rgb(255 255 255 / 0.08);
border: 1px solid rgb(var(--pl-fg) / 0.08);
border-radius: 0.75rem;
}
.perkCellPremium {
border-color: rgb(167 159 251 / 0.35);
background-color: rgb(99 94 233 / 0.14);
border-color: rgb(var(--flux-light) / 0.35);
background-color: rgb(var(--flux-glow-light) / 0.06);
box-shadow: inset 0 1px 0 rgb(var(--flux-light) / 0.3);
}
.perkCellTerm {
color: rgb(255 255 255 / 0.55);
color: rgb(var(--pl-fg) / 0.55);
font-size: 0.9375rem;
line-height: 1.5;
}
@@ -811,13 +967,13 @@
.perkCellValue {
margin: 0;
color: rgb(255 255 255 / 0.7);
color: rgb(var(--pl-fg) / 0.7);
font-size: 1rem;
line-height: 1.6;
}
.perkCellPremium .perkCellValue {
color: #fff;
color: rgb(var(--pl-fg));
font-weight: 600;
}
@@ -843,9 +999,9 @@
.tableHead {
padding: 1rem;
vertical-align: middle;
border: 0 solid rgb(255 255 255 / 0.1);
border: 0 solid rgb(var(--pl-fg) / 0.1);
border-bottom-width: 1px;
color: rgb(255 255 255 / 0.7);
color: rgb(var(--pl-fg) / 0.7);
font-size: 1.0625rem;
font-weight: 600;
line-height: 1.35;
@@ -867,7 +1023,7 @@
.tableRow {
vertical-align: middle;
border: 0 solid rgb(255 255 255 / 0.1);
border: 0 solid rgb(var(--pl-fg) / 0.1);
border-bottom-width: 1px;
}
@@ -878,7 +1034,7 @@
.tableRowHead {
padding: 1.25rem 1rem;
vertical-align: middle;
color: #fff;
color: rgb(var(--pl-fg));
font-size: 1rem;
font-weight: 600;
line-height: 1.6;
@@ -901,14 +1057,14 @@
.tableCell {
padding: 1.25rem 1rem;
vertical-align: middle;
color: rgb(255 255 255 / 0.6);
color: rgb(var(--pl-fg) / 0.6);
font-size: 1.0625rem;
line-height: 1.35;
text-align: center;
}
.tableCellPremium {
color: #fff;
color: rgb(var(--pl-fg));
font-weight: 600;
}
@@ -925,11 +1081,11 @@
}
.booleanIconMuted {
color: rgb(255 255 255 / 0.6);
color: rgb(var(--pl-fg) / 0.6);
}
.booleanIconMissing {
color: rgb(255 255 255 / 0.3);
color: rgb(var(--pl-fg) / 0.3);
}
.comparisonActions {
@@ -956,7 +1112,7 @@
.closingBody {
max-width: 42rem;
margin: var(--pl-stack) auto var(--pl-group);
color: rgb(255 255 255 / 0.85);
color: rgb(var(--pl-fg) / 0.85);
font-size: var(--pl-text-lead);
line-height: 1.55;
text-wrap: pretty;
@@ -976,7 +1132,7 @@
.footnote {
max-width: var(--pl-measure);
margin: 0 auto;
color: rgb(255 255 255 / 0.65);
color: rgb(var(--pl-fg) / 0.65);
font-size: 0.9375rem;
line-height: 1.5;
text-align: center;
@@ -995,3 +1151,17 @@
white-space: nowrap;
border-width: 0;
}
@media (forced-colors: active) {
.hero::before,
.starfield::before,
.glassPanel::before,
.perkRow::before {
display: none;
}
.scroller,
.glassPanelBrand {
background-image: none;
}
}
@@ -254,7 +254,7 @@ export function PlutoniumPageShowcase({
return (
<article
key={card.id}
className={clsx(styles.glassPanel, styles.perkRow, flipped && styles.perkRowFlipped)}
className={clsx(styles.perkRow, flipped && styles.perkRowFlipped)}
data-flx={`premium.plutonium-page.perk-row.${card.id}`}
>
<div className={styles.perkArtColumn} data-flx="premium.plutonium-page.perk-row.art-column">
@@ -715,6 +715,7 @@ export const VoiceTab: React.FC<VoiceTabProps> = observer(({voiceSettings, autoR
echoCancellation,
autoGainControl,
voiceProcessingMode,
stereoMicrophone: isStereoMicrophoneEnabled(),
}}
data-flx="user.voice-tab.mic-test-section"
/>
@@ -1,7 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createVoiceSoftClipNode} from '@app/features/voice/engine/VoiceSharedAudioContext';
import {type VoiceInputConfig, VoiceInputGraph} from '@app/features/voice/utils/VoiceInputProcessor';
import {
type VoiceInputChannelCount,
type VoiceInputConfig,
VoiceInputGraph,
} from '@app/features/voice/utils/VoiceInputProcessor';
export interface MicTestAudioGraph {
analyser: AnalyserNode;
@@ -16,6 +20,7 @@ interface CreateMicTestAudioGraphOptions {
signal: AbortSignal;
source: MediaStreamAudioSourceNode;
sourceTrack: MediaStreamTrack;
channelCount: VoiceInputChannelCount;
resolveConfig: () => VoiceInputConfig;
outputGain: number;
playbackTarget: AudioNode;
@@ -26,6 +31,7 @@ export function createMicTestAudioGraph({
signal,
source,
sourceTrack,
channelCount,
resolveConfig,
outputGain,
playbackTarget,
@@ -33,7 +39,7 @@ export function createMicTestAudioGraph({
}: CreateMicTestAudioGraphOptions): MicTestAudioGraph {
signal.throwIfAborted();
const audioContext = source.context as AudioContext;
const inputGraph = new VoiceInputGraph(audioContext, 1, resolveConfig);
const inputGraph = new VoiceInputGraph(audioContext, channelCount, resolveConfig);
inputGraph.setSourceNode(sourceTrack, source);
const analyser = audioContext.createAnalyser();
const delay = audioContext.createDelay(Math.max(1, playbackDelaySeconds + 0.25));
@@ -36,6 +36,7 @@ export interface MicTestSettings {
echoCancellation: boolean;
autoGainControl: boolean;
voiceProcessingMode: VoiceProcessingMode;
stereoMicrophone: boolean;
}
function readEffectiveNoiseSuppressionBackend(): VoiceNoiseSuppressionBackend {
@@ -77,6 +78,7 @@ export const useMicTest = (settings: MicTestSettings) => {
echoCancellation: settings.echoCancellation,
autoGainControl: settings.autoGainControl,
voiceProcessingMode: settings.voiceProcessingMode,
stereoMicrophone: settings.stereoMicrophone,
noiseSuppressionBackend,
}),
[
@@ -84,6 +86,7 @@ export const useMicTest = (settings: MicTestSettings) => {
settings.echoCancellation,
settings.inputDeviceId,
settings.outputDeviceId,
settings.stereoMicrophone,
settings.voiceProcessingMode,
noiseSuppressionBackend,
],
@@ -177,12 +180,17 @@ export const useMicTest = (settings: MicTestSettings) => {
return exhaustive;
}
}
const profile = resolveVoiceProcessing(settings, readEffectiveNoiseSuppressionBackend(), false);
const profile = resolveVoiceProcessing(
settings,
readEffectiveNoiseSuppressionBackend(),
settings.stereoMicrophone,
);
const baseAudioConstraints: MediaTrackConstraints & {voiceIsolation?: boolean} = {
echoCancellation: profile.echoCancellation,
noiseSuppression: profile.browserNoiseSuppression,
autoGainControl: profile.autoGainControl,
voiceIsolation: false,
...(profile.stereoCapture ? {channelCount: {ideal: 2}} : {}),
};
const useExactDeviceId = settings.inputDeviceId !== 'default';
const buildAudioConstraints = (exact: boolean): MediaTrackConstraints =>
@@ -227,6 +235,7 @@ export const useMicTest = (settings: MicTestSettings) => {
signal: controller.signal,
source: acquired.source,
sourceTrack,
channelCount: profile.stereoCapture ? 2 : 1,
resolveConfig: () => ({
backend: resolveVoiceProcessing(settings, readEffectiveNoiseSuppressionBackend(), false)
.noiseSuppressionBackend,
@@ -344,14 +344,6 @@ const BOOTSTRAP_ENDPOINT = 'https://primary.test/api';
registration: {collect_date_of_birth: true},
},
},
geoip: {
countryCode: null,
regionCode: null,
latitude: null,
longitude: null,
ageRestrictedGeos: [],
ageBlockedGeos: [],
},
};
const {VoiceEngineV2AppScreenShareExecutionAdapter, shouldRestoreScreenShareAfterReconnect} = await import(
@@ -37,6 +37,7 @@ import {
type LocalTrackPublication,
type LocalVideoTrack,
type ScreenShareCaptureOptions,
supportsScalabilityMode,
Track,
type TrackPublishOptions,
type VideoCodec,
@@ -265,6 +266,7 @@ function resolveScreenShareEncoding(target: ScreenShareTarget, publishOptions?:
}
function getScreenShareLayeringForCodec(codec: VideoCodec | undefined): ScreenShareLayering {
if (!supportsScalabilityMode()) return {simulcast: false, scalabilityMode: undefined};
return resolveScreenShareLayering({
codec,
svcSetting: VoiceSettings.getScreenShareScalabilityModeOverride(),
@@ -5,7 +5,7 @@ import type {TrackPublishOptions} from 'livekit-client';
export const OPUS_MAX_AUDIO_BITRATE_BPS = 510000;
export const VOICE_CHANNEL_MIN_AUDIO_BITRATE_BPS = 8000;
export const STEREO_VOICE_MIN_AUDIO_BITRATE_BPS = 128000;
export const STEREO_VOICE_MIN_AUDIO_BITRATE_BPS = 64000;
export const SCREEN_SHARE_AUDIO_BITRATE_BPS = 128000;
export function normaliseAudioBitrateBps(value: number | null | undefined): number | undefined {
@@ -182,29 +182,12 @@ function getScreenShareCodecPolicyUnsupported(
'H.265 (HEVC) screen sharing is off by default because it may cause compatibility issues for viewers. We’re working on improving this. Turn on H.265 screen sharing in Advanced settings to use it.',
};
}
if (context.firefox) {
switch (codec) {
case 'av1':
return {
supported: false,
reason: 'unsupported-browser',
detail: 'Firefox doesn\u2019t support AV1 encoding for WebRTC yet.',
};
case 'vp9':
return {
supported: false,
reason: 'unsupported-browser',
detail: 'Firefox\u2019s WebRTC stack doesn\u2019t expose VP9 as a publishable codec.',
};
case 'h265':
return {
supported: false,
reason: 'unsupported-browser',
detail: 'Firefox doesn\u2019t support H.265 encoding for WebRTC.',
};
default:
break;
}
if (context.firefox && codec === 'h265') {
return {
supported: false,
reason: 'unsupported-browser',
detail: 'Firefox doesn\u2019t support H.265 encoding for WebRTC.',
};
}
return null;
}
@@ -4,6 +4,8 @@ import type {CodecPreference, ScreenShareEncoderMode} from '@app/features/voice/
import type {VideoCodec} from 'livekit-client';
export const CODEC_PREFERENCE: ReadonlyArray<VideoCodec> = ['av1', 'h265', 'h264', 'vp9', 'vp8'];
const SOFTWARE_H264_CODEC_PREFERENCE: ReadonlyArray<VideoCodec> = ['av1', 'h265', 'vp9', 'h264', 'vp8'];
const GECKO_CODEC_PREFERENCE: ReadonlyArray<VideoCodec> = ['vp8', 'h264'];
const COMPATIBILITY_CODECS: ReadonlySet<VideoCodec> = new Set(['h264', 'vp9', 'vp8']);
export const LAST_RESORT_VIDEO_CODEC: VideoCodec = 'vp8';
const BASELINE_BROWSER_CODECS: ReadonlySet<VideoCodec> = new Set(['h264', 'vp8']);
@@ -103,7 +105,13 @@ export function rankScreenShareCodecs(input: ScreenShareCodecRankingInput): Scre
if (codec === 'h265') return pin === 'h265' || (input.encoderModeSetting !== 'software' && isHardware('h265'));
return true;
};
const survivors = CODEC_PREFERENCE.filter(survives);
const preference =
input.profile.browser === 'firefox'
? GECKO_CODEC_PREFERENCE
: isHardware('h264')
? CODEC_PREFERENCE
: SOFTWARE_H264_CODEC_PREFERENCE;
const survivors = preference.filter(survives);
const ranked =
input.encoderModeSetting === 'software'
? survivors
@@ -52,7 +52,7 @@ const STEREO_MICROPHONE_DESCRIPTOR = msg({
comment: 'Switch label in the voice settings that sends both channels of a stereo microphone. Keep it concise.',
});
const STEREO_MICROPHONE_DESCRIPTION_DESCRIPTOR = msg({
message: 'Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher.',
message: 'Sends both channels of a stereo microphone. Needs all processing off and a channel at 64 kbps or higher.',
comment: 'Description for the stereo microphone switch in the voice settings.',
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 21 KiB

+1
View File
@@ -16,6 +16,7 @@ rand = "0.10"
reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls", "stream", "gzip", "brotli", "deflate"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
sha2 = "0.11.0"
tokio = { version = "1.53.1", features = ["macros", "net", "rt-multi-thread", "signal", "time", "fs"] }
tokio-util = { version = "0.7.19", features = ["io"] }
tower = { version = "0.5.3", features = ["util"] }
+39 -54
View File
@@ -9,7 +9,6 @@ use serde::Serialize;
pub struct BootstrapPayload<'a> {
pub config: BootstrapConfig<'a>,
pub instance: &'a serde_json::Value,
pub geoip: &'a serde_json::Value,
}
#[derive(Serialize)]
@@ -38,12 +37,7 @@ struct LegacyConfig<'a> {
bootstrap_api_public_endpoint: Option<&'a str>,
}
pub fn build_bootstrap_script(
config: &AppProxyConfig,
discovery: &DiscoveryResponse,
geoip: &serde_json::Value,
nonce: &str,
) -> String {
pub fn build_bootstrap_script(config: &AppProxyConfig, discovery: &DiscoveryResponse) -> String {
let api_public_endpoint =
api_public_endpoint(config.bootstrap_api_public_endpoint.as_deref(), discovery);
@@ -54,7 +48,6 @@ pub fn build_bootstrap_script(
bootstrap_api_public_endpoint: api_public_endpoint,
},
instance: &discovery.data,
geoip,
};
let legacy = LegacyConfig {
@@ -67,7 +60,7 @@ pub fn build_bootstrap_script(
let legacy_json = escape_json_for_script(&serde_json::to_string(&legacy).unwrap());
format!(
r#"<script nonce="{nonce}">window.__FLUXER_BOOTSTRAP__={bootstrap_json};window.__FLUXER_CONFIG__={legacy_json};</script>"#
r#"<script>window.__FLUXER_BOOTSTRAP__={bootstrap_json};window.__FLUXER_CONFIG__={legacy_json};</script>"#
)
}
@@ -144,7 +137,6 @@ const STATIC_PRECONNECT_TAGS: [&str; 2] = [
pub fn inject_bootstrap(
html: &str,
nonce: &str,
script_tag: &str,
static_cdn_endpoint: &str,
media_endpoint: &str,
@@ -152,35 +144,34 @@ pub fn inject_bootstrap(
let static_cdn = static_cdn_endpoint.trim_end_matches('/');
let media = media_endpoint.trim_end_matches('/');
let nonced = html.replace("{{CSP_NONCE_PLACEHOLDER}}", nonce);
let nonced = apply_static_preconnect(nonced, static_cdn);
let nonced = nonced.replace("{{STATIC_CDN_ENDPOINT}}", static_cdn);
let nonced = apply_media_preconnect(&nonced, media, static_cdn);
let html = apply_static_preconnect(html.to_owned(), static_cdn);
let html = html.replace("{{STATIC_CDN_ENDPOINT}}", static_cdn);
let html = apply_media_preconnect(&html, media, static_cdn);
if nonced.contains("<!--{{FLUXER_BOOTSTRAP}}-->") {
return nonced.replace("<!--{{FLUXER_BOOTSTRAP}}-->", script_tag);
if html.contains("<!--{{FLUXER_BOOTSTRAP}}-->") {
return html.replace("<!--{{FLUXER_BOOTSTRAP}}-->", script_tag);
}
if nonced.contains("{{FLUXER_BOOTSTRAP}}") {
return nonced.replace("{{FLUXER_BOOTSTRAP}}", script_tag);
if html.contains("{{FLUXER_BOOTSTRAP}}") {
return html.replace("{{FLUXER_BOOTSTRAP}}", script_tag);
}
let insert_at = nonced
let insert_at = html
.find("<head>")
.map(|pos| pos + "<head>".len())
.or_else(|| {
let pos = nonced.find("<head ")?;
nonced[pos..].find('>').map(|close| pos + close + 1)
let pos = html.find("<head ")?;
html[pos..].find('>').map(|close| pos + close + 1)
});
if let Some(insert_at) = insert_at {
let mut result = String::with_capacity(nonced.len() + script_tag.len() + 3);
result.push_str(&nonced[..insert_at]);
let mut result = String::with_capacity(html.len() + script_tag.len() + 3);
result.push_str(&html[..insert_at]);
result.push_str("\n\t\t");
result.push_str(script_tag);
result.push_str(&nonced[insert_at..]);
result.push_str(&html[insert_at..]);
return result;
}
nonced
html
}
fn apply_static_preconnect(mut html: String, static_cdn: &str) -> String {
@@ -219,7 +210,6 @@ mod tests {
fn inject_into_shipped_shell() -> String {
inject_bootstrap(
SHIPPED_APP_SHELL,
"shellnonce",
"<script>boot</script>",
"https://cdn.example.test/",
"https://media.example.test/",
@@ -256,16 +246,20 @@ mod tests {
let result = inject_into_shipped_shell();
assert!(!result.contains("{{STATIC_CDN_ENDPOINT}}"));
assert!(!result.contains("{{MEDIA_ENDPOINT}}"));
assert!(!result.contains("{{CSP_NONCE_PLACEHOLDER}}"));
assert!(!result.contains("{{FLUXER_BOOTSTRAP}}"));
assert!(result.contains("<script>boot</script>"));
assert!(result.contains(r#"nonce="shellnonce""#));
}
#[test]
fn shipped_shell_carries_no_nonce_attribute_or_placeholder() {
assert!(!SHIPPED_APP_SHELL.contains("nonce"));
assert!(!SHIPPED_APP_SHELL.contains("{{CSP_NONCE_PLACEHOLDER}}"));
}
#[test]
fn inject_bootstrap_before_head_close() {
let html = "<html><head><title>App</title></head><body></body></html>";
let result = inject_bootstrap(html, "abc123", "<script>boot</script>", "", "");
let result = inject_bootstrap(html, "<script>boot</script>", "", "");
assert!(result.contains("<script>boot</script>"));
assert!(result.contains("<head>"));
}
@@ -273,7 +267,7 @@ mod tests {
#[test]
fn inject_bootstrap_fluxer_placeholder() {
let html = "<html><head>{{FLUXER_BOOTSTRAP}}</head></html>";
let result = inject_bootstrap(html, "n1", "<script>x</script>", "", "");
let result = inject_bootstrap(html, "<script>x</script>", "", "");
assert!(result.contains("<script>x</script>"));
assert!(!result.contains("{{FLUXER_BOOTSTRAP}}"));
}
@@ -281,25 +275,16 @@ mod tests {
#[test]
fn inject_bootstrap_comment_placeholder() {
let html = "<html><head><!--{{FLUXER_BOOTSTRAP}}--></head></html>";
let result = inject_bootstrap(html, "n2", "<script>y</script>", "", "");
let result = inject_bootstrap(html, "<script>y</script>", "", "");
assert!(result.contains("<script>y</script>"));
assert!(!result.contains("<!--{{FLUXER_BOOTSTRAP}}-->"));
}
#[test]
fn inject_bootstrap_replaces_csp_nonce_placeholder() {
let html = r#"<html><head><script nonce="{{CSP_NONCE_PLACEHOLDER}}"></script>{{FLUXER_BOOTSTRAP}}</head></html>"#;
let result = inject_bootstrap(html, "mynonce", "<script>z</script>", "", "");
assert!(result.contains(r#"nonce="mynonce""#));
assert!(!result.contains("{{CSP_NONCE_PLACEHOLDER}}"));
}
#[test]
fn inject_bootstrap_replaces_static_cdn_endpoint_placeholder() {
let html = r#"<html><head><link href="{{STATIC_CDN_ENDPOINT}}/web/favicon-32x32.png">{{FLUXER_BOOTSTRAP}}</head></html>"#;
let result = inject_bootstrap(
html,
"nonce",
"<script>boot</script>",
"https://cdn.example.test/",
"",
@@ -315,7 +300,6 @@ mod tests {
{{FLUXER_BOOTSTRAP}}</head></html>"#;
let result = inject_bootstrap(
html,
"nonce",
"<script>boot</script>",
"https://cdn.example.test/",
"https://media.example.test/",
@@ -332,7 +316,6 @@ mod tests {
{{FLUXER_BOOTSTRAP}}</head></html>"#;
let result = inject_bootstrap(
html,
"nonce",
"<script>boot</script>",
"https://cdn.example.test",
"",
@@ -349,7 +332,6 @@ mod tests {
{{FLUXER_BOOTSTRAP}}</head></html>"#;
let result = inject_bootstrap(
html,
"nonce",
"<script>boot</script>",
"https://cdn.example.test",
"https://cdn.example.test/",
@@ -367,7 +349,6 @@ mod tests {
fn static_cdn_keeps_a_credentialed_and_an_anonymous_preconnect() {
let result = inject_bootstrap(
SHELL_PRECONNECT_HEAD,
"nonce",
"<script>boot</script>",
"https://cdn.example.test/",
"https://media.example.test",
@@ -384,7 +365,6 @@ mod tests {
fn both_static_preconnects_are_dropped_when_the_endpoint_is_empty() {
let result = inject_bootstrap(
SHELL_PRECONNECT_HEAD,
"nonce",
"<script>boot</script>",
"",
"https://media.example.test",
@@ -422,7 +402,6 @@ mod tests {
#[test]
fn bootstrap_payload_serialization_field_names() {
let instance = serde_json::json!({"name": "test"});
let geoip = serde_json::json!({"country": "SE"});
let payload = BootstrapPayload {
config: BootstrapConfig {
release_channel: "stable",
@@ -430,20 +409,18 @@ mod tests {
bootstrap_api_public_endpoint: None,
},
instance: &instance,
geoip: &geoip,
};
let json = serde_json::to_string(&payload).unwrap();
assert!(json.contains(r#""releaseChannel""#));
assert!(json.contains(r#""bootstrapApiEndpoint""#));
assert!(json.contains(r#""config""#));
assert!(json.contains(r#""instance""#));
assert!(json.contains(r#""geoip""#));
assert!(!json.contains("geoip"));
}
#[test]
fn bootstrap_config_serializes_public_endpoint_when_present() {
let instance = serde_json::json!({});
let geoip = serde_json::json!({});
let payload = BootstrapPayload {
config: BootstrapConfig {
release_channel: "canary",
@@ -451,7 +428,6 @@ mod tests {
bootstrap_api_public_endpoint: Some("https://pub.example.com/api"),
},
instance: &instance,
geoip: &geoip,
};
let json = serde_json::to_string(&payload).unwrap();
assert!(json.contains(r#""bootstrapApiPublicEndpoint""#));
@@ -460,7 +436,6 @@ mod tests {
#[test]
fn bootstrap_config_omits_public_endpoint_when_none() {
let instance = serde_json::json!({});
let geoip = serde_json::json!({});
let payload = BootstrapPayload {
config: BootstrapConfig {
release_channel: "stable",
@@ -468,7 +443,6 @@ mod tests {
bootstrap_api_public_endpoint: None,
},
instance: &instance,
geoip: &geoip,
};
let json = serde_json::to_string(&payload).unwrap();
assert!(!json.contains("bootstrapApiPublicEndpoint"));
@@ -611,8 +585,7 @@ mod tests {
let discovery = discovery_offering("https://chat.example.test:8443/api");
let mut config = AppProxyConfig::from_env();
config.bootstrap_api_public_endpoint = Some("https://chat.example.test/api".to_owned());
let script =
build_bootstrap_script(&config, &discovery, &serde_json::json!({}), "scriptnonce");
let script = build_bootstrap_script(&config, &discovery);
assert!(
script.contains(r#""bootstrapApiPublicEndpoint":"https://chat.example.test:8443/api""#)
);
@@ -621,4 +594,16 @@ mod tests {
));
assert!(!script.contains(r#""https://chat.example.test/api""#));
}
#[test]
fn the_boot_script_is_a_bare_inline_script_with_nothing_per_visitor() {
let discovery = discovery_offering("https://chat.example.test/api");
let config = AppProxyConfig::from_env();
let script = build_bootstrap_script(&config, &discovery);
assert!(script.starts_with("<script>window.__FLUXER_BOOTSTRAP__="));
assert!(script.ends_with("</script>"));
assert!(!script.contains("nonce"));
assert!(!script.contains("geoip"));
assert!(!script.contains("countryCode"));
}
}
+177 -71
View File
@@ -3,15 +3,82 @@
use crate::config::{AppProxyConfig, CspConfig, CspSource, HttpEndpoint};
use axum::http::HeaderValue;
use axum::http::header::InvalidHeaderValue;
use rand::RngExt;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use sha2::{Digest, Sha256};
const CSP_NONCE_HEX_DIGITS: usize = 32;
const CSP_VALIDATION_NONCE: &str = "00000000000000000000000000000000";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InlineScriptHash(String);
const _: () = assert!(
CSP_VALIDATION_NONCE.len() == CSP_NONCE_HEX_DIGITS,
"the nonce a policy is validated with must be shaped like the nonce a request carries"
);
impl InlineScriptHash {
pub fn of(script_text: &str) -> Self {
Self(format!(
"'sha256-{}'",
STANDARD.encode(Sha256::digest(script_text.as_bytes()))
))
}
pub fn as_source(&self) -> &str {
&self.0
}
}
pub fn inline_script_hashes(document: &str) -> Vec<InlineScriptHash> {
let mut hashes: Vec<InlineScriptHash> = Vec::new();
let mut rest = document;
while let Some((attributes, text, after)) = next_script_element(rest) {
rest = after;
if has_src_attribute(attributes) {
continue;
}
let hash = InlineScriptHash::of(text);
if !hashes.contains(&hash) {
hashes.push(hash);
}
}
hashes
}
fn next_script_element(html: &str) -> Option<(&str, &str, &str)> {
let mut offset = 0;
loop {
let start = offset + find_ignoring_ascii_case(&html[offset..], "<script")?;
let name_end = start + "<script".len();
let boundary = *html.as_bytes().get(name_end)?;
if boundary != b'>' && boundary != b'/' && !boundary.is_ascii_whitespace() {
offset = name_end;
continue;
}
let tag_end = name_end + html[name_end..].find('>')?;
let text_start = tag_end + 1;
let text_end = text_start + find_ignoring_ascii_case(&html[text_start..], "</script")?;
let close_end = html[text_end..]
.find('>')
.map_or(html.len(), |index| text_end + index + 1);
return Some((
&html[name_end..tag_end],
&html[text_start..text_end],
&html[close_end..],
));
}
}
fn find_ignoring_ascii_case(haystack: &str, needle: &str) -> Option<usize> {
haystack
.as_bytes()
.windows(needle.len())
.position(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
}
fn has_src_attribute(attributes: &str) -> bool {
attributes
.split(|c: char| c.is_ascii_whitespace() || c == '/')
.any(|token| {
token
.split('=')
.next()
.is_some_and(|name| name.eq_ignore_ascii_case("src"))
})
}
#[derive(Clone, Debug, Default)]
pub struct RuntimeCspSources {
@@ -129,7 +196,7 @@ impl CompiledCspPolicy {
.map_err(CspCompileError::InvalidAssetPolicy)?;
HeaderValue::from_str(&build_csp(
&config,
CSP_VALIDATION_NONCE,
&[InlineScriptHash::of("")],
configured_sources,
))
.map_err(CspCompileError::InvalidSpaPolicy)?;
@@ -140,26 +207,24 @@ impl CompiledCspPolicy {
self.asset.clone()
}
pub fn spa_header(&self, nonce: &str, runtime_sources: &RuntimeCspSources) -> HeaderValue {
assert!(
nonce.len() == CSP_NONCE_HEX_DIGITS
&& nonce.bytes().all(|byte| byte.is_ascii_hexdigit()),
"a CSP nonce must be a 128-bit hexadecimal value"
);
HeaderValue::from_str(&build_csp(&self.config, nonce, runtime_sources)).expect(
"every CSP source is a validated keyword, scheme, or ASCII origin, so a policy built \
from them is always a valid header value",
pub fn spa_header(
&self,
script_hashes: &[InlineScriptHash],
runtime_sources: &RuntimeCspSources,
) -> HeaderValue {
HeaderValue::from_str(&build_csp(&self.config, script_hashes, runtime_sources)).expect(
"every CSP source is a validated keyword, scheme, ASCII origin, or base64 hash, so a \
policy built from them is always a valid header value",
)
}
}
pub fn generate_nonce() -> String {
let bytes: [u8; 16] = rand::rng().random();
hex::encode(bytes)
}
fn build_csp(config: &CspConfig, nonce: &str, runtime_sources: &RuntimeCspSources) -> String {
build_csp_directives(config, Some(nonce), runtime_sources).join("; ")
fn build_csp(
config: &CspConfig,
script_hashes: &[InlineScriptHash],
runtime_sources: &RuntimeCspSources,
) -> String {
build_csp_directives(config, Some(script_hashes), runtime_sources).join("; ")
}
fn build_asset_csp(config: &CspConfig, runtime_sources: &RuntimeCspSources) -> String {
@@ -168,7 +233,7 @@ fn build_asset_csp(config: &CspConfig, runtime_sources: &RuntimeCspSources) -> S
fn build_csp_directives(
config: &CspConfig,
nonce: Option<&str>,
script_hashes: Option<&[InlineScriptHash]>,
runtime_sources: &RuntimeCspSources,
) -> Vec<String> {
let mut directives = Vec::with_capacity(14);
@@ -182,8 +247,8 @@ fn build_csp_directives(
"'wasm-unsafe-eval'".to_owned(),
"blob:".to_owned(),
];
if let Some(n) = nonce {
script.insert(1, format!("'nonce-{n}'"));
if let Some(hashes) = script_hashes {
script.splice(1..1, hashes.iter().map(|hash| hash.as_source().to_owned()));
}
extend_from(&mut script, &config.extra_script_src, SCRIPT_SOURCES);
extend_runtime_sources(&mut script, runtime_sources, true, false);
@@ -290,21 +355,52 @@ fn extend_from(target: &mut Vec<String>, extra: &[CspSource], defaults: &[&str])
mod tests {
use super::*;
#[test]
fn generate_nonce_produces_32_char_hex() {
let nonce = generate_nonce();
assert_eq!(nonce.len(), CSP_NONCE_HEX_DIGITS);
assert!(nonce.chars().all(|c| c.is_ascii_hexdigit()));
CompiledCspPolicy::compile(default_csp_config(), &runtime_sources())
.unwrap()
.spa_header(&nonce, &runtime_sources());
fn hash_of(text: &str) -> InlineScriptHash {
InlineScriptHash::of(text)
}
#[test]
fn generate_nonce_is_random() {
let a = generate_nonce();
let b = generate_nonce();
assert_ne!(a, b);
fn an_inline_script_hash_is_the_base64_sha256_of_the_exact_script_text() {
assert_eq!(
hash_of("alert('Hello, world.');").as_source(),
"'sha256-qznLcsROx4GACP2dm0UCKCzCG+HiZ1guq6ZZDob/Tng='"
);
assert_eq!(
hash_of("").as_source(),
"'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='"
);
}
#[test]
fn every_inline_script_is_hashed_and_external_scripts_are_not() {
let document = concat!(
"<head><script>first()</script>",
"<SCRIPT type=\"text/javascript\">second()</SCRIPT >",
"<script type=\"module\" src=\"/assets/app.js\"></script>",
"<script defer src='/assets/vendor.js'></script>",
"<scripts>not a script</scripts>",
"<script data-src=\"x\">\nthird()\n</script></head>",
);
assert_eq!(
inline_script_hashes(document),
vec![
hash_of("first()"),
hash_of("second()"),
hash_of("\nthird()\n")
]
);
}
#[test]
fn an_inline_script_repeated_verbatim_is_granted_once() {
let document = "<script>same()</script><script>same()</script>";
assert_eq!(inline_script_hashes(document), vec![hash_of("same()")]);
}
#[test]
fn an_unterminated_script_is_never_granted() {
assert!(inline_script_hashes("<script>never_closed()").is_empty());
}
fn default_csp_config() -> CspConfig {
@@ -322,7 +418,7 @@ mod tests {
#[test]
fn build_csp_includes_required_directives() {
let config = default_csp_config();
let csp = build_csp(&config, "testnonce", &runtime_sources());
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources());
assert!(csp.contains("default-src"));
assert!(csp.contains("script-src"));
assert!(csp.contains("style-src"));
@@ -341,7 +437,7 @@ mod tests {
#[test]
fn build_csp_allows_blob_connections_for_camera_background_media() {
let config = default_csp_config();
let csp = build_csp(&config, "testnonce", &runtime_sources());
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources());
let connect = csp
.split("; ")
.find(|directive| directive.starts_with("connect-src "))
@@ -369,22 +465,40 @@ mod tests {
}
#[test]
fn build_csp_includes_nonce_in_script_src() {
fn build_csp_grants_each_script_hash_in_script_src_and_no_nonce() {
let config = default_csp_config();
let csp = build_csp(&config, "abc123def456", &runtime_sources());
assert!(csp.contains("'nonce-abc123def456'"));
}
#[test]
fn build_asset_csp_excludes_nonce() {
let config = default_csp_config();
let csp = build_asset_csp(&config, &runtime_sources());
let csp = build_csp(
&config,
&[hash_of("first()"), hash_of("second()")],
&runtime_sources(),
);
let script = csp
.split("; ")
.find(|directive| directive.starts_with("script-src "))
.expect("script-src directive");
assert!(script.starts_with(&format!(
"script-src 'self' {} {} 'wasm-unsafe-eval' blob:",
hash_of("first()").as_source(),
hash_of("second()").as_source()
)));
assert!(!csp.contains("nonce-"));
}
#[test]
fn build_asset_csp_grants_no_inline_script() {
let config = default_csp_config();
let csp = build_asset_csp(&config, &runtime_sources());
assert!(!csp.contains("nonce-"));
assert!(!csp.contains("sha256-"));
}
#[test]
fn build_csp_allows_no_third_party_captcha_hosts() {
let csp = build_csp(&default_csp_config(), "test-nonce", &runtime_sources());
let csp = build_csp(
&default_csp_config(),
&[hash_of("boot()")],
&runtime_sources(),
);
assert!(!csp.contains("hcaptcha"));
assert!(!csp.contains("challenges.cloudflare.com"));
}
@@ -392,7 +506,7 @@ mod tests {
#[test]
fn csp_no_double_spaces_or_trailing_semicolons() {
let config = default_csp_config();
let csp = build_csp(&config, "nonce1", &runtime_sources());
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources());
assert!(!csp.contains(" "), "CSP contains double spaces");
assert!(!csp.ends_with(';'), "CSP ends with semicolon");
assert!(!csp.ends_with("; "), "CSP ends with semicolon+space");
@@ -410,14 +524,14 @@ mod tests {
),
..Default::default()
};
let csp = build_csp(&config, "nonce1", &runtime_sources());
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources());
assert!(csp.contains("report-uri https://example.com/csp-report"));
}
#[test]
fn build_csp_excludes_report_uri_when_none() {
let config = default_csp_config();
let csp = build_csp(&config, "nonce1", &runtime_sources());
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources());
assert!(!csp.contains("report-uri"));
}
@@ -429,7 +543,7 @@ mod tests {
media_endpoint: Some(endpoint("https://media.example.test")),
..Default::default()
};
let csp = build_csp(&config, "nonce1", &runtime_sources);
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources);
assert!(csp.contains("style-src 'self' 'unsafe-inline'"));
assert!(csp.contains("https://static.example.test"));
assert!(csp.contains("https://media.example.test"));
@@ -471,7 +585,7 @@ mod tests {
..Default::default()
};
let csp = build_csp(&config, "nonce1", &runtime_sources);
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources);
assert!(csp.contains("http://localhost:3900"));
assert!(csp.contains("http://fluxer-uploads.localhost:3900"));
@@ -492,6 +606,7 @@ mod tests {
let asset = policy.asset_header();
let asset = asset.to_str().unwrap();
assert!(!asset.contains("nonce-"));
assert!(!asset.contains("sha256-"));
assert!(asset.contains("https://static.example.test"));
assert!(
!asset.contains("https://media.example.test"),
@@ -501,7 +616,7 @@ mod tests {
}
#[test]
fn a_compiled_policy_stamps_the_requests_own_nonce_and_discovery_endpoints() {
fn a_compiled_policy_stamps_the_documents_script_hashes_and_discovery_endpoints() {
let policy = CompiledCspPolicy::compile(default_csp_config(), &runtime_sources()).unwrap();
let discovered = RuntimeCspSources {
static_cdn_endpoint: Some(endpoint("https://cdn.discovered.test")),
@@ -509,10 +624,10 @@ mod tests {
..Default::default()
};
let header = policy.spa_header("0123456789abcdef0123456789abcdef", &discovered);
let header = policy.spa_header(&[hash_of("boot()")], &discovered);
let header = header.to_str().unwrap();
assert!(header.contains("'nonce-0123456789abcdef0123456789abcdef'"));
assert!(header.contains(hash_of("boot()").as_source()));
assert!(header.contains("https://cdn.discovered.test"));
assert!(header.contains("https://branding.discovered.test"));
}
@@ -530,19 +645,10 @@ mod tests {
policy.asset_header().to_str().unwrap(),
build_asset_csp(&config, &sources)
);
let hashes = [hash_of("boot()")];
assert_eq!(
policy
.spa_header(CSP_VALIDATION_NONCE, &sources)
.to_str()
.unwrap(),
build_csp(&config, CSP_VALIDATION_NONCE, &sources)
policy.spa_header(&hashes, &sources).to_str().unwrap(),
build_csp(&config, &hashes, &sources)
);
}
#[test]
#[should_panic(expected = "a CSP nonce must be a 128-bit hexadecimal value")]
fn a_compiled_policy_refuses_a_nonce_it_did_not_generate() {
let policy = CompiledCspPolicy::compile(default_csp_config(), &runtime_sources()).unwrap();
policy.spa_header("not-a-nonce", &runtime_sources());
}
}
+7 -1
View File
@@ -6,7 +6,7 @@ use fluxer_app_proxy::{
csp::CompiledCspPolicy,
discovery_cache::DiscoveryCache,
geoip,
routes::build_router,
routes::{build_router, present_local_asset_prefixes},
state::{
AppProxyBudgets, AppState, MAX_SPA_INDEX_BYTES, build_http_client, read_bounded_text_file,
},
@@ -67,6 +67,11 @@ fn main() -> anyhow::Result<()> {
None
};
let local_asset_prefixes = config
.index_upstream_url
.is_none()
.then(|| present_local_asset_prefixes(&config.static_dir));
let state = AppState {
config,
csp,
@@ -74,6 +79,7 @@ fn main() -> anyhow::Result<()> {
discovery_cache,
geoip,
index_html,
local_asset_prefixes,
budgets: AppProxyBudgets::default(),
};
@@ -510,6 +510,7 @@ mod tests {
client_ip_header_name: "x-forwarded-for".to_owned(),
})),
index_html: None,
local_asset_prefixes: None,
budgets: crate::state::AppProxyBudgets::default(),
}
}
@@ -0,0 +1,98 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::geoip::build_geoip_response;
use crate::state::AppState;
use axum::{
Json,
extract::State,
http::{HeaderMap, HeaderValue, header},
response::{IntoResponse, Response},
};
pub const CLIENT_GEOIP_PATH: &str = "/_geoip";
const CLIENT_GEOIP_CACHE_CONTROL: &str = "no-store, private";
pub async fn client_geoip(State(state): State<AppState>, headers: HeaderMap) -> Response {
let mut response = Json(build_geoip_response(state.geoip.lookup(&headers))).into_response();
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static(CLIENT_GEOIP_CACHE_CONTROL),
);
response
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AppProxyConfig;
use crate::discovery_cache::DiscoveryCache;
use crate::routes::build_router;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use fluxer_common::config::GeoipSourceConfig;
use fluxer_common::geoip::{GeoipConfig, GeoipLookup, GeoipResolver};
use std::sync::Arc;
use tower::ServiceExt;
fn geoip_state() -> AppState {
let config = AppProxyConfig::from_env();
let csp = Arc::new(
crate::csp::CompiledCspPolicy::from_config(&config)
.expect("the test configuration must compile to a valid CSP"),
);
AppState {
config: Arc::new(config),
csp,
http_client: reqwest::Client::new(),
discovery_cache: Arc::new(DiscoveryCache::new()),
geoip: Arc::new(GeoipResolver::from_config(&GeoipConfig {
geoip_source: GeoipSourceConfig::Filesystem {
maxmind_db_path: None,
},
geoip_s3_config: None,
trust_client_ip_header: true,
client_ip_header_name: "x-forwarded-for".to_owned(),
})),
index_html: Some(Arc::from("<html><head></head><body></body></html>")),
local_asset_prefixes: None,
budgets: crate::state::AppProxyBudgets::default(),
}
}
#[tokio::test]
async fn the_geoip_endpoint_answers_for_the_requesting_client_and_is_never_stored() {
let response = build_router(geoip_state())
.oneshot(
Request::get(CLIENT_GEOIP_PATH)
.header("x-forwarded-for", "203.0.113.10")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let headers = response.headers();
assert_eq!(
headers.get(header::CACHE_CONTROL).unwrap(),
CLIENT_GEOIP_CACHE_CONTROL
);
assert!(
headers
.get(header::CONTENT_TYPE)
.unwrap()
.to_str()
.unwrap()
.starts_with("application/json"),
"the SPA fallback answered the geoip endpoint with the app shell"
);
assert!(headers.get("x-fluxer-app-shell").is_none());
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(body, build_geoip_response(GeoipLookup::default()));
assert_eq!(body["countryCode"], serde_json::Value::Null);
}
}
+1
View File
@@ -77,6 +77,7 @@ mod tests {
client_ip_header_name: "x-forwarded-for".to_owned(),
})),
index_html: None,
local_asset_prefixes: None,
budgets: crate::state::AppProxyBudgets::default(),
}
}
+7
View File
@@ -3,11 +3,14 @@
mod android_association;
mod apple_association;
mod assets_proxy;
mod client_geoip;
mod file_stream;
mod health;
mod spa_index;
mod spa_static;
pub use spa_index::present_local_asset_prefixes;
use crate::state::AppState;
use axum::{
Router,
@@ -35,6 +38,10 @@ pub fn build_router(state: AppState) -> Router {
Router::new()
.route("/_health", get(health::health))
.route("/_ready", get(health::ready))
.route(
client_geoip::CLIENT_GEOIP_PATH,
get(client_geoip::client_geoip),
)
.route(
"/.well-known/apple-app-site-association",
get(apple_association::apple_app_site_association),
+271 -59
View File
@@ -4,9 +4,8 @@ use crate::bootstrap::{
build_bootstrap_script, inject_bootstrap, rewrite_endpoints_for_same_origin_host,
};
use crate::config::{AppProxyConfig, HttpEndpoint};
use crate::csp::{RuntimeCspSources, generate_nonce};
use crate::csp::{RuntimeCspSources, inline_script_hashes};
use crate::discovery_cache::{DiscoveryResponse, discovery_endpoint};
use crate::geoip::build_geoip_response;
use crate::state::{
AppProxyBudgets, AppState, MAX_RENDERED_SPA_INDEX_BYTES, MAX_SPA_INDEX_BYTES,
read_bounded_text_file,
@@ -18,6 +17,7 @@ use axum::{
response::{IntoResponse, Response},
};
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use super::assets_proxy::serve_local_asset;
@@ -27,6 +27,7 @@ use super::spa_static::{CORS_ALLOW_ANY_VALUE, guess_mime, is_font_mime};
const ACCEPT_CH_VALUE: &str = "DPR, Sec-CH-DPR, Sec-CH-Width, Save-Data, ECT, Downlink";
const CRITICAL_CH_VALUE: &str = "Sec-CH-DPR, Sec-CH-Width, Save-Data";
const DEV_NO_STORE_CACHE_CONTROL: &str = "no-store, no-cache, must-revalidate, max-age=0";
const SHARED_SHELL_CACHE_CONTROL: &str = "public, max-age=0, s-maxage=1";
pub async fn spa_catch_all(
State(state): State<AppState>,
@@ -45,7 +46,14 @@ pub async fn spa_catch_all(
)
.await;
}
if is_static_asset_path(request_path) {
if let Some(prefix) = static_asset_prefix(request_path) {
if state
.local_asset_prefixes
.as_ref()
.is_some_and(|present| !present.contains(&prefix))
{
return StatusCode::NOT_FOUND.into_response();
}
return serve_local_asset(
&state.budgets,
&state.config.static_dir,
@@ -80,10 +88,23 @@ fn static_root_file_cache_control(request_path: &str) -> Option<&'static str> {
.map(|(_, cache_control)| *cache_control)
}
fn is_static_asset_path(request_path: &str) -> bool {
fn static_asset_prefix(request_path: &str) -> Option<&'static str> {
STATIC_ASSET_PREFIXES
.iter()
.any(|prefix| request_path.starts_with(prefix))
.copied()
.find(|prefix| request_path.starts_with(prefix))
}
pub fn present_local_asset_prefixes(static_dir: &str) -> Arc<[&'static str]> {
STATIC_ASSET_PREFIXES
.iter()
.copied()
.filter(|prefix| {
Path::new(static_dir)
.join(prefix.trim_matches('/'))
.is_dir()
})
.collect()
}
async fn serve_static_file(
@@ -154,7 +175,6 @@ async fn serve_spa_index(state: &AppState, headers: &HeaderMap) -> Response {
rewrite_endpoints_for_same_origin_host(&mut discovery.data, host);
}
let nonce = generate_nonce();
let runtime_csp_sources = build_runtime_csp_sources(state, &discovery);
let static_cdn_endpoint = runtime_csp_sources
.static_cdn_endpoint
@@ -164,9 +184,7 @@ async fn serve_spa_index(state: &AppState, headers: &HeaderMap) -> Response {
.media_endpoint
.as_ref()
.map_or("", HttpEndpoint::as_str);
let csp = state.csp.spa_header(&nonce, &runtime_csp_sources);
let geoip = build_geoip_response(state.geoip.lookup(headers));
let script_tag = build_bootstrap_script(&state.config, &discovery, &geoip, &nonce);
let script_tag = build_bootstrap_script(&state.config, &discovery);
let raw_html = match load_spa_index_html(state).await {
Ok(content) => content,
@@ -181,7 +199,6 @@ async fn serve_spa_index(state: &AppState, headers: &HeaderMap) -> Response {
let dev_buster = should_bust_dev_assets.then(current_dev_asset_cache_buster);
let html = match render_spa_document(
&raw_html,
&nonce,
&script_tag,
static_cdn_endpoint,
media_endpoint,
@@ -193,8 +210,10 @@ async fn serve_spa_index(state: &AppState, headers: &HeaderMap) -> Response {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let html = html.into_boxed_str();
build_spa_response(html, csp, should_bust_dev_assets)
let csp = state
.csp
.spa_header(&inline_script_hashes(&html), &runtime_csp_sources);
build_spa_response(html.into_boxed_str(), csp, should_bust_dev_assets)
}
fn same_origin_host<'a>(config: &'a AppProxyConfig, headers: &HeaderMap) -> Option<&'a str> {
@@ -248,7 +267,6 @@ fn bounded_document(document: String) -> Result<String, SpaDocumentSizeLimitErro
fn render_spa_document(
html: &str,
nonce: &str,
script_tag: &str,
static_cdn_endpoint: &str,
media_endpoint: &str,
@@ -256,7 +274,6 @@ fn render_spa_document(
) -> Result<String, SpaDocumentSizeLimitError> {
let mut document = bounded_document(inject_bootstrap(
html,
nonce,
script_tag,
static_cdn_endpoint,
media_endpoint,
@@ -457,7 +474,10 @@ fn build_spa_response(html: Box<str>, csp: HeaderValue, dev_no_store: bool) -> R
HeaderValue::from_static("no-store"),
);
} else {
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static(SHARED_SHELL_CACHE_CONTROL),
);
}
super::set_security_headers(headers);
headers.insert(
@@ -610,11 +630,11 @@ mod tests {
use crate::config::{AppProxyConfig, ReleaseChannel};
use crate::discovery_cache::DiscoveryCache;
use crate::state::LOCAL_FILE_READS_IN_FLIGHT_MAX;
use axum::Router;
use axum::body::Body;
use fluxer_common::config::GeoipSourceConfig;
use fluxer_common::geoip::{GeoipConfig, GeoipResolver};
use std::sync::Arc;
#[test]
fn dev_asset_cache_buster_rewrites_script_and_link_assets() {
@@ -664,13 +684,12 @@ mod tests {
assert!(!is_static_root_file("/users/1.2.3"));
}
const SHELL_WITH_A_NONCE_HOLE: &str = r#"<!doctype html><html><head><title>Fluxer</title><script nonce="{{CSP_NONCE_PLACEHOLDER}}"></script><script src="/assets/app.js"></script></head><body></body></html>"#;
const SHELL_WITH_AN_INLINE_SCRIPT: &str = r#"<!doctype html><html><head><title>Fluxer</title><script>inline()</script><script src="/assets/app.js"></script></head><body></body></html>"#;
#[test]
fn the_rendered_document_always_carries_the_bootstrap_and_a_real_nonce() {
fn the_rendered_document_always_carries_the_bootstrap() {
let rendered = render_spa_document(
SHELL_WITH_A_NONCE_HOLE,
"reqnonce",
SHELL_WITH_AN_INLINE_SCRIPT,
"<script>booted</script>",
"https://static.example.test",
"",
@@ -678,16 +697,15 @@ mod tests {
)
.expect("test SPA document must render within its size limit");
assert!(!rendered.contains("{{CSP_NONCE_PLACEHOLDER}}"));
assert!(rendered.contains(r#"nonce="reqnonce""#));
assert!(rendered.contains("<script>booted</script>"));
assert!(rendered.contains("<script>inline()</script>"));
assert!(!rendered.contains("nonce"));
}
#[test]
fn the_dev_cache_buster_reaches_the_rendered_document_only_when_supplied() {
let busted = render_spa_document(
SHELL_WITH_A_NONCE_HOLE,
"reqnonce",
SHELL_WITH_AN_INLINE_SCRIPT,
"<script>booted</script>",
"",
"",
@@ -695,8 +713,7 @@ mod tests {
)
.expect("test SPA document must render within its size limit");
let untouched = render_spa_document(
SHELL_WITH_A_NONCE_HOLE,
"reqnonce",
SHELL_WITH_AN_INLINE_SCRIPT,
"<script>booted</script>",
"",
"",
@@ -712,13 +729,12 @@ mod tests {
const SHELL_WITH_ENDPOINT_HOLES: &str = r#"<!doctype html><html><head><title>Fluxer</title><link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}">
<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}" crossorigin>
<link rel="preconnect" href="{{MEDIA_ENDPOINT}}">
<link rel="icon" type="image/png" sizes="32x32" href="{{STATIC_CDN_ENDPOINT}}/web/favicon-32x32.png"><link rel="apple-touch-icon" sizes="180x180" href="{{STATIC_CDN_ENDPOINT}}/web/apple-touch-icon.png"><script nonce="{{CSP_NONCE_PLACEHOLDER}}"></script><script src="/assets/app.js"></script></head><body></body></html>"#;
<link rel="icon" type="image/png" sizes="32x32" href="{{STATIC_CDN_ENDPOINT}}/web/favicon-32x32.png"><link rel="apple-touch-icon" sizes="180x180" href="{{STATIC_CDN_ENDPOINT}}/web/apple-touch-icon.png"><script>inline()</script><script src="/assets/app.js"></script></head><body></body></html>"#;
#[test]
fn the_static_cdn_argument_resolves_every_hole_the_shell_carries() {
let rendered = render_spa_document(
SHELL_WITH_ENDPOINT_HOLES,
"reqnonce",
"<script>booted</script>",
"https://cdn.example.test/",
"https://media.example.test",
@@ -751,7 +767,6 @@ mod tests {
fn the_media_argument_is_resolved_and_weighed_against_the_static_cdn() {
let distinct = render_spa_document(
SHELL_WITH_ENDPOINT_HOLES,
"reqnonce",
"<script>booted</script>",
"https://cdn.example.test",
"https://media.example.test/",
@@ -767,7 +782,6 @@ mod tests {
let shared = render_spa_document(
SHELL_WITH_ENDPOINT_HOLES,
"reqnonce",
"<script>booted</script>",
"https://cdn.example.test",
"https://cdn.example.test",
@@ -830,6 +844,79 @@ mod tests {
assert!(served.contains("window.__FLUXER_BOOTSTRAP__"));
}
fn static_dir_with(prefix_dirs: &[&str]) -> std::path::PathBuf {
static NEXT_DIR: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let unique = NEXT_DIR.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let root = std::env::temp_dir().join(format!(
"fluxer-static-prefixes-{}-{unique}",
std::process::id()
));
std::fs::create_dir_all(&root).unwrap();
for dir in prefix_dirs {
std::fs::create_dir_all(root.join(dir)).unwrap();
}
root
}
#[test]
fn only_prefixes_with_a_directory_on_disk_are_present() {
let root = static_dir_with(&["emoji", "web"]);
std::fs::write(root.join("badges"), b"a file, not a directory").unwrap();
let present = present_local_asset_prefixes(root.to_str().unwrap());
assert_eq!(&*present, &["/emoji/", "/web/"]);
std::fs::remove_dir_all(root).unwrap();
}
#[tokio::test]
async fn a_prefix_missing_at_startup_is_refused_without_a_file_read_slot() {
let mut state = spa_state_serving(ReleaseChannel::Stable, Some(SHIPPED_APP_SHELL)).await;
state.local_asset_prefixes = Some(Arc::from([] as [&str; 0]));
let _every_slot = state
.budgets
.local_read_slots
.clone()
.try_acquire_many_owned(LOCAL_FILE_READS_IN_FLIGHT_MAX as u32)
.unwrap();
let response = spa_catch_all(
State(state),
HeaderMap::new(),
Request::builder()
.uri("/emoji/1f600.svg")
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn a_prefix_present_at_startup_is_still_served_from_disk() {
let root = static_dir_with(&["emoji"]);
std::fs::write(root.join("emoji").join("1f600.svg"), b"<svg/>").unwrap();
let mut state = spa_state_serving(ReleaseChannel::Stable, Some(SHIPPED_APP_SHELL)).await;
let mut config = (*state.config).clone();
config.static_dir = root.to_str().unwrap().to_owned();
state.local_asset_prefixes = Some(present_local_asset_prefixes(&config.static_dir));
state.config = Arc::new(config);
let response = spa_catch_all(
State(state),
HeaderMap::new(),
Request::builder()
.uri("/emoji/1f600.svg")
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
std::fs::remove_dir_all(root).unwrap();
}
#[tokio::test]
async fn the_official_instance_keeps_its_link_preview_metadata() {
let state = spa_state_serving(ReleaseChannel::Stable, Some(SHIPPED_APP_SHELL)).await;
@@ -930,25 +1017,77 @@ mod tests {
client_ip_header_name: "x-forwarded-for".to_owned(),
})),
index_html: cached_shell.map(Arc::from),
local_asset_prefixes: None,
budgets: crate::state::AppProxyBudgets::default(),
}
}
fn nonce_granted_by(response: &Response) -> String {
let policy = response
fn policy_of(response: &Response) -> String {
response
.headers()
.get(header::CONTENT_SECURITY_POLICY)
.expect("the document was served without a content security policy")
.to_str()
.unwrap();
let opening = policy
.find("'nonce-")
.expect("the content security policy granted no nonce at all");
let remainder = &policy[opening + "'nonce-".len()..];
let closing = remainder
.find('\'')
.expect("the content security policy left its nonce source unterminated");
remainder[..closing].to_owned()
.unwrap()
.to_owned()
}
fn script_hashes_granted_by(policy: &str) -> Vec<String> {
policy
.split("; ")
.find(|directive| directive.starts_with("script-src "))
.expect("the content security policy has no script-src directive")
.split(' ')
.filter(|source| source.starts_with("'sha256-"))
.map(str::to_owned)
.collect()
}
fn bare_inline_scripts_in(document: &str) -> Vec<&str> {
document
.split("<script>")
.skip(1)
.map(|rest| {
&rest[..rest
.find("</script>")
.expect("an inline script in the served document is never closed")]
})
.collect()
}
fn sha256_source(text: &str) -> String {
use base64::Engine as _;
use sha2::Digest as _;
format!(
"'sha256-{}'",
base64::engine::general_purpose::STANDARD.encode(sha2::Sha256::digest(text))
)
}
fn assert_every_inline_script_is_granted(document: &str, policy: &str) {
for tag in document.split("<script").skip(1) {
let tag = &tag[..tag.find('>').unwrap()];
assert!(
tag.is_empty() || tag.contains(" src="),
"the served document carries a script tag the test cannot classify: <script{tag}>"
);
}
let inline = bare_inline_scripts_in(document);
assert!(
!inline.is_empty(),
"the served document carries no inline script at all"
);
let mut expected: Vec<String> = inline.iter().map(|script| sha256_source(script)).collect();
expected.sort();
expected.dedup();
let mut granted = script_hashes_granted_by(policy);
granted.sort();
assert_eq!(
granted, expected,
"the policy must grant exactly the inline scripts the document carries"
);
assert!(!document.contains("nonce"));
assert!(!policy.contains("nonce"));
}
async fn read_document(response: Response) -> String {
@@ -1059,26 +1198,14 @@ mod tests {
let response = serve_spa_index(&state, &HeaderMap::new()).await;
assert_eq!(response.status(), StatusCode::OK);
let granted_nonce = nonce_granted_by(&response);
let policy = policy_of(&response);
let served = read_document(response).await;
assert!(
!served.contains("{{CSP_NONCE_PLACEHOLDER}}"),
"the live branch shipped an unfilled nonce hole"
);
assert!(
served.contains(&format!(
r#"<script nonce="{granted_nonce}">window.__FLUXER_BOOTSTRAP__"#
)),
"the live bootstrap was not granted the nonce its own policy header carries"
);
assert_eq!(
served
.matches(&format!(r#"nonce="{granted_nonce}""#))
.count(),
served.matches(r#"nonce=""#).count(),
"the live document carries a nonce its own policy header never granted"
served.contains("<script>window.__FLUXER_BOOTSTRAP__"),
"the live bootstrap is not a bare inline script"
);
assert_every_inline_script_is_granted(&served, &policy);
assert!(
!served.contains("{{STATIC_CDN_ENDPOINT}}"),
"the live branch shipped an unresolved static CDN hole"
@@ -1159,9 +1286,11 @@ mod tests {
.to_str()
.unwrap();
assert_eq!(
cache_control, "no-cache",
"the document naming the hashed bundle must be revalidated on every load"
cache_control, SHARED_SHELL_CACHE_CONTROL,
"the document naming the hashed bundle must be revalidated on every load and held \
by a shared cache for one second at most"
);
assert!(response.headers().get(header::SET_COOKIE).is_none());
assert_ne!(
cache_control, LONG_LIVED_ASSET_CACHE_CONTROL,
"a shell cached for a year pins every returning visitor to the deployed-over bundle"
@@ -1254,6 +1383,89 @@ mod tests {
);
}
fn request_from_visitor(ip: &str, country: &str, language: &str) -> HeaderMap {
let mut headers = request_from_host("web.fluxer.app");
for (name, value) in [
("x-forwarded-for", ip),
("cf-connecting-ip", ip),
("cf-ipcountry", country),
("accept-language", language),
("cookie", "session=visitor-specific"),
] {
headers.insert(
HeaderName::from_static(name),
HeaderValue::from_str(value).unwrap(),
);
}
headers
}
#[tokio::test]
async fn every_visitor_to_a_host_gets_a_byte_identical_shell_and_policy() {
let mut state = assemble_spa_state(
ReleaseChannel::Stable,
Some(SHIPPED_APP_SHELL),
DISCOVERY_BODY_WITH_WEB_APP_ENDPOINTS,
None,
None,
)
.await;
let mut config = (*state.config).clone();
config.same_origin_hosts = vec!["web.fluxer.app".to_owned()];
config.trust_client_ip_header = true;
state.config = Arc::new(config);
let stockholm =
serve_spa_index(&state, &request_from_visitor("81.234.0.1", "SE", "sv-SE")).await;
let sao_paulo =
serve_spa_index(&state, &request_from_visitor("177.0.0.1", "BR", "pt-BR")).await;
assert_eq!(stockholm.status(), StatusCode::OK);
assert_eq!(sao_paulo.status(), StatusCode::OK);
let stockholm_policy = policy_of(&stockholm);
let sao_paulo_policy = policy_of(&sao_paulo);
assert_eq!(stockholm_policy, sao_paulo_policy);
assert!(stockholm.headers().get(header::SET_COOKIE).is_none());
assert!(sao_paulo.headers().get(header::SET_COOKIE).is_none());
let stockholm_body = read_document(stockholm).await;
let sao_paulo_body = read_document(sao_paulo).await;
assert_eq!(stockholm_body, sao_paulo_body);
assert!(!stockholm_body.contains("geoip"));
assert!(!stockholm_body.contains("countryCode"));
assert_every_inline_script_is_granted(&stockholm_body, &stockholm_policy);
}
#[tokio::test]
async fn the_shipped_shell_runs_every_inline_script_it_carries_under_its_policy() {
let state = spa_state_serving(ReleaseChannel::Stable, Some(SHIPPED_APP_SHELL)).await;
let response = serve_spa_index(&state, &HeaderMap::new()).await;
assert_eq!(response.status(), StatusCode::OK);
let policy = policy_of(&response);
let served = read_document(response).await;
assert_eq!(
bare_inline_scripts_in(&served).len(),
bare_inline_scripts_in(SHIPPED_APP_SHELL).len() + 1,
"every inline script of fluxer_app/index.html plus the bootstrap must reach the document"
);
assert_every_inline_script_is_granted(&served, &policy);
}
#[tokio::test]
async fn an_index_upstream_document_is_granted_after_its_dev_cache_buster() {
let index_upstream_url = spawn_local_origin(SHIPPED_APP_SHELL, "text/html").await;
let state = spa_state_reading_its_shell_from(index_upstream_url).await;
let response = serve_spa_index(&state, &HeaderMap::new()).await;
assert_eq!(response.status(), StatusCode::OK);
let policy = policy_of(&response);
let served = read_document(response).await;
assert_every_inline_script_is_granted(&served, &policy);
}
#[test]
fn font_mime_types_are_cors_enabled() {
assert!(is_font_mime("font/woff2"));
+1
View File
@@ -44,6 +44,7 @@ pub struct AppState {
pub discovery_cache: Arc<DiscoveryCache>,
pub geoip: Arc<GeoipResolver>,
pub index_html: Option<Arc<str>>,
pub local_asset_prefixes: Option<Arc<[&'static str]>>,
pub budgets: AppProxyBudgets,
}
+1
View File
@@ -269,6 +269,7 @@ const ACCEPTED_TABLE_FINDINGS = new Map<string, Readonly<Partial<Record<TableRul
['admin-api/applications.mdx', {'table-identifier': 1}],
['admin-api/bulk-jobs.mdx', {'table-fit': 1}],
['admin-api/discovery.mdx', {'table-identifier': 1}],
['admin-api/gateway.mdx', {'table-identifier': 2}],
['admin-api/guilds.mdx', {'table-identifier': 3}],
['admin-api/index.mdx', {'table-identifier': 3}],
['admin-api/instance.mdx', {'table-identifier': 8}],
@@ -24,6 +24,9 @@ One snapshot of the Gateway cluster, taken across the nodes the request polled.
| --- | --- | --- |
| status<sup>1</sup> | string | The aggregate health of the cluster |
| sessions | integer | The connected client Gateway sessions, summed across nodes |
| session_resumes_total | integer | The total number of session resumes, summed across nodes |
| websocket_dispatches_total | integer | The total number of WebSocket dispatches, summed across nodes |
| websocket_dispatch_drops_total | integer | The total number of WebSocket dispatches dropped, summed across nodes |
| guilds | integer | The live guild processes, summed across nodes |
| presences | integer | The tracked presences, summed across nodes |
| calls | integer | The live calls, summed across nodes |
@@ -48,6 +51,9 @@ One snapshot of the Gateway cluster, taken across the nodes the request polled.
{
"status": "healthy",
"sessions": 4820,
"session_resumes_total": 1234,
"websocket_dispatches_total": 1234567,
"websocket_dispatch_drops_total": 123,
"guilds": 1913,
"presences": 4611,
"calls": 12,
@@ -71,6 +77,9 @@ One entry for each node the request polled, including nodes that did not answer
| node_id<sup>1</sup> | string | The name the node reports for itself |
| status<sup>2</sup> | string | The health this node reported |
| sessions | integer | The connected client Gateway sessions on this node |
| session_resumes_total | integer | The total number of session resumes on this node |
| websocket_dispatches_total | integer | The total number of WebSocket dispatches on this node |
| websocket_dispatch_drops_total | integer | The total number of WebSocket dispatches dropped on this node |
| guilds | integer | The live guild processes on this node |
| presences | integer | The tracked presences on this node |
| calls | integer | The live calls on this node |
@@ -1570,7 +1570,7 @@ Defaults to the crate version. The reported build of `admin` and `app-proxy`. `B
## Content Security Policy
`app-proxy` builds a per-request nonce-based policy for the client HTML and the assets it serves. Each variable appends sources to one directive on top of the built-in ones. All are empty by default, and Compose forwards every one.
`app-proxy` builds the policy for the client HTML and the assets it serves. The client HTML policy allows each inline script by its SHA-256 hash, so every visitor to a host gets the same document and header. Each variable appends sources to one directive on top of the built-in ones. All are empty by default, and Compose forwards every one.
Every name below goes in `.env`. `app-proxy` reads its environment at container start, so a change takes effect on `docker compose up -d app-proxy`. A `docker compose restart app-proxy` does not apply it.
@@ -395,6 +395,7 @@ Forward every path and query string unchanged. The edge handles routing:
| `/.well-known/apple-app-site-association`, `/apple-app-site-association` | Apple app association |
| `/.well-known/assetlinks.json` | Android app association |
| `/version.json` | Client version metadata |
| `/_geoip` | Client location lookup |
All other paths serve the web app. The admin path follows `FLUXER_ADMIN_BASE_PATH`, `/admin` by default.
@@ -54,6 +54,21 @@ is_positive_int "${FLUXER_ERLANG_SCHEDULERS_MIN:-}" || FLUXER_ERLANG_SCHEDULERS_
is_positive_int "${FLUXER_ERLANG_SCHEDULERS_MAX:-}" || FLUXER_ERLANG_SCHEDULERS_MAX=16
: "${FLUXER_ERLANG_NODE_NAME:[email protected]}"
: "${FLUXER_ERLANG_DIST_PORT:=8081}"
node_name_file=/opt/fluxer_gateway/node_name
if [ -n "${FLUXER_ERLANG_NODE_NAME_PREFIX:-}" ]; then
case "$FLUXER_ERLANG_NODE_NAME_PREFIX" in
*[!A-Za-z0-9_]*)
echo 'FLUXER_ERLANG_NODE_NAME_PREFIX may only contain letters, digits and underscores.' >&2
exit 1
;;
esac
node_nonce="$(od -An -N4 -tx1 /dev/urandom | tr -d ' \n')"
FLUXER_ERLANG_NODE_NAME="${FLUXER_ERLANG_NODE_NAME_PREFIX}_${node_nonce}@${FLUXER_ERLANG_NODE_NAME#*@}"
printf '%s\n' "$FLUXER_ERLANG_NODE_NAME" >"$node_name_file"
else
rm -f "$node_name_file"
fi
if [ -z "${FLUXER_ERLANG_COOKIE:-}" ]; then
echo 'FLUXER_ERLANG_COOKIE is required.' >&2
@@ -13,4 +13,9 @@ eval | console | remote_console | remote | remsh | console_clean | console_boot
esac
script_dir="$(CDPATH='' cd "$(dirname "$0")" && pwd -P)"
node_name_file="$script_dir/../node_name"
if [ -r "$node_name_file" ]; then
FLUXER_ERLANG_NODE_NAME="$(cat "$node_name_file")"
export FLUXER_ERLANG_NODE_NAME
fi
exec "$script_dir/fluxer_gateway.real" "$@"
@@ -16,6 +16,7 @@ start(_StartType, _StartArgs) ->
-spec init_subsystems() -> ok.
init_subsystems() ->
_ = fluxer_gateway_env:load(),
ok = gateway_guild_pin_keeper:apply_boot_pins(),
gateway_compress:init(),
gateway_cluster_metrics:init(),
process_registry:init(),
@@ -114,6 +114,17 @@ env_gateway_base_config() ->
<<"gateway_nats_rpc_max_handlers">> => env_int(
"FLUXER_GATEWAY_NATS_RPC_MAX_HANDLERS", 512
),
<<"nats_rpc_enabled">> => env_bool("FLUXER_GATEWAY_NATS_RPC_ENABLED", true),
<<"pinned_guild_ids">> => env_optional_binary("FLUXER_GATEWAY_PINNED_GUILD_IDS"),
<<"guild_pin_keeper_beam">> => env_optional_binary(
"FLUXER_GATEWAY_GUILD_PIN_KEEPER_BEAM"
),
<<"guild_pin_keeper_beam_md5">> => env_optional_binary(
"FLUXER_GATEWAY_GUILD_PIN_KEEPER_BEAM_MD5"
),
<<"guild_pin_keeper_base_md5s">> => env_optional_binary(
"FLUXER_GATEWAY_GUILD_PIN_KEEPER_BASE_MD5S"
),
<<"gateway_http_failure_threshold">> => env_int(
"FLUXER_GATEWAY_HTTP_FAILURE_THRESHOLD", 6
),
@@ -142,6 +153,7 @@ build_config(RawConfig) ->
build_sharding_config(Service),
build_http_config(Service),
build_cluster_config(Service, Public),
build_pinned_node_config(Service),
build_misc_config(Service)
]).
@@ -195,7 +207,7 @@ build_sharding_config(Service) ->
guild_counts_cache_shards => get_optional_int(Service, <<"guild_counts_cache_shards">>),
guild_shards => get_optional_int(Service, <<"guild_shards">>),
session_shards => get_optional_int(Service, <<"session_shards">>),
session_connect_max_queue => get_int(Service, <<"session_connect_max_queue">>, 1024)
session_connect_max_queue => get_int(Service, <<"session_connect_max_queue">>, 8192)
}.
-spec build_http_config(map()) -> config().
@@ -233,6 +245,39 @@ build_cluster_config(Service, Public) ->
)
}.
-spec build_pinned_node_config(map()) -> config().
build_pinned_node_config(Service) ->
#{
nats_rpc_enabled => get_bool(Service, <<"nats_rpc_enabled">>, true),
pinned_guild_ids => parse_guild_id_list(
get_optional_binary(Service, <<"pinned_guild_ids">>)
),
guild_pin_keeper_beam => optional_string(
get_optional_binary(Service, <<"guild_pin_keeper_beam">>)
),
guild_pin_keeper_beam_md5 => get_optional_binary(
Service, <<"guild_pin_keeper_beam_md5">>
),
guild_pin_keeper_base_md5s => get_optional_binary(
Service, <<"guild_pin_keeper_base_md5s">>
)
}.
-spec parse_guild_id_list(binary() | undefined) -> [pos_integer()].
parse_guild_id_list(undefined) ->
[];
parse_guild_id_list(Bin) when is_binary(Bin) ->
lists:usort([parse_guild_id(Token) || Token <- string:lexemes(binary_to_list(Bin), ", ")]).
-spec parse_guild_id(string()) -> pos_integer().
parse_guild_id(Token) ->
try list_to_integer(Token) of
Id when Id > 0 -> Id;
_ -> erlang:error({invalid_pinned_guild_id, Token})
catch
error:badarg -> erlang:error({invalid_pinned_guild_id, Token})
end.
-spec build_misc_config(map()) -> config().
build_misc_config(Service) ->
#{
@@ -43,7 +43,8 @@ common_children() ->
[
child_spec(gateway_dispatch_relay, gateway_dispatch_relay),
child_spec(gateway_periodic_gc, gateway_periodic_gc),
child_spec(process_health_watchdog, process_health_watchdog)
child_spec(process_health_watchdog, process_health_watchdog),
child_spec(gateway_stall_monitor, gateway_stall_monitor)
].
-spec role_children(atom()) -> [supervisor:child_spec()].
@@ -95,7 +96,7 @@ role_specs(guilds, _Role) ->
child_spec(guild_counts_cache, guild_counts_cache),
child_spec(guild_manager, guild_manager),
child_spec(voice_state_counts_sync, voice_state_counts_sync)
];
] ++ gateway_guild_pin_keeper:child_specs();
role_specs(calls, Role) ->
[child_spec(call_manager, call_manager)] ++ calls_voice_state_counts_sync_children(Role);
role_specs(push, _Role) ->
@@ -8,6 +8,12 @@
record_discovery_resolve_failure/0,
record_membership_transition/1,
record_owner_resolution/1,
record_resume/0,
resumes_total/0,
record_dispatch/0,
dispatches_total/0,
record_dispatch_drop/0,
dispatch_drops_total/0,
snapshot/0,
reset_for_tests/0
]).
@@ -18,7 +24,10 @@
-define(MEMBERSHIP_DOWN_IDX, 3).
-define(OWNER_SELF_IDX, 4).
-define(OWNER_PEER_IDX, 5).
-define(COUNTER_COUNT, 5).
-define(RESUMES_IDX, 6).
-define(DISPATCHES_IDX, 7).
-define(DISPATCH_DROPS_IDX, 8).
-define(COUNTER_COUNT, 8).
-spec init() -> ok.
init() ->
@@ -41,11 +50,38 @@ record_owner_resolution(self) ->
record_owner_resolution(peer) ->
add(?OWNER_PEER_IDX, 1).
-spec record_resume() -> ok.
record_resume() ->
add(?RESUMES_IDX, 1).
-spec resumes_total() -> non_neg_integer().
resumes_total() ->
counters:get(counters_ref(), ?RESUMES_IDX).
-spec record_dispatch() -> ok.
record_dispatch() ->
add(?DISPATCHES_IDX, 1).
-spec dispatches_total() -> non_neg_integer().
dispatches_total() ->
counters:get(counters_ref(), ?DISPATCHES_IDX).
-spec record_dispatch_drop() -> ok.
record_dispatch_drop() ->
add(?DISPATCH_DROPS_IDX, 1).
-spec dispatch_drops_total() -> non_neg_integer().
dispatch_drops_total() ->
counters:get(counters_ref(), ?DISPATCH_DROPS_IDX).
-spec snapshot() -> map().
snapshot() ->
Counters = counters_ref(),
#{
<<"gateway_cluster_member_count">> => gateway_cluster_membership:alive_count(),
<<"session_resumes_total">> => counters:get(Counters, ?RESUMES_IDX),
<<"websocket_dispatches_total">> => counters:get(Counters, ?DISPATCHES_IDX),
<<"websocket_dispatch_drops_total">> => counters:get(Counters, ?DISPATCH_DROPS_IDX),
<<"gateway_cluster_discovery_resolve_failures_total">> =>
counters:get(Counters, ?DISCOVERY_RESOLVE_FAILURES_IDX),
<<"gateway_cluster_membership_transitions_total">> => #{
@@ -83,7 +119,7 @@ valid_counters_or_new(Counters) ->
validate_counters(Counters) ->
try
Ref = eqwalizer:dynamic_cast(Counters),
_ = counters:get(Ref, ?DISCOVERY_RESOLVE_FAILURES_IDX),
_ = counters:get(Ref, ?COUNTER_COUNT),
{ok, Ref}
catch
_:_ -> error
@@ -108,6 +144,7 @@ snapshot_defaults_to_zero_test() ->
persistent_term:erase({gateway_cluster_membership, members}),
Snapshot = snapshot(),
?assertEqual(1, maps:get(<<"gateway_cluster_member_count">>, Snapshot)),
?assertEqual(0, maps:get(<<"session_resumes_total">>, Snapshot)),
?assertEqual(0, maps:get(<<"gateway_cluster_discovery_resolve_failures_total">>, Snapshot)),
Membership = maps:get(<<"gateway_cluster_membership_transitions_total">>, Snapshot),
?assertEqual(0, maps:get(<<"up">>, Membership)),
@@ -117,16 +154,41 @@ records_counters_test() ->
reset_for_tests(),
record_discovery_resolve_failure(),
record_membership_transition(up),
record_resume(),
record_membership_transition(down),
record_owner_resolution(self),
record_owner_resolution(peer),
Snapshot = snapshot(),
?assertEqual(1, maps:get(<<"gateway_cluster_discovery_resolve_failures_total">>, Snapshot)),
Membership = maps:get(<<"gateway_cluster_membership_transitions_total">>, Snapshot),
?assertEqual(1, maps:get(<<"session_resumes_total">>, Snapshot)),
Owners = maps:get(<<"gateway_node_router_owner_resolutions_total">>, Snapshot),
?assertEqual(1, maps:get(<<"up">>, Membership)),
?assertEqual(1, maps:get(<<"down">>, Membership)),
?assertEqual(1, maps:get(<<"self">>, Owners)),
?assertEqual(1, maps:get(<<"peer">>, Owners)).
dispatch_counter_test() ->
reset_for_tests(),
?assertEqual(0, dispatches_total()),
record_dispatch(),
record_dispatch(),
?assertEqual(2, dispatches_total()),
?assertEqual(2, maps:get(<<"websocket_dispatches_total">>, snapshot())).
dispatch_drop_counter_test() ->
reset_for_tests(),
?assertEqual(0, dispatch_drops_total()),
record_dispatch_drop(),
record_dispatch_drop(),
?assertEqual(2, dispatch_drops_total()),
?assertEqual(0, dispatches_total()),
?assertEqual(2, maps:get(<<"websocket_dispatch_drops_total">>, snapshot())).
undersized_counters_test() ->
persistent_term:put(?COUNTERS_KEY, counters:new(5, [write_concurrency])),
record_dispatch(),
?assertEqual(1, dispatches_total()),
reset_for_tests().
-endif.
File diff suppressed because it is too large Load Diff
@@ -138,8 +138,10 @@ dispatch_pre_encoded(Event, EncodedData, Seq, #{compress_ctx := CompressCtx} = S
case gateway_compress:compress(JsonFrame, Ctx) of
{ok, Compressed, NewCompressCtx} ->
Frame = gateway_handler_encode:make_frame(Compressed, text, NewCompressCtx),
gateway_cluster_metrics:record_dispatch(),
{[Frame], State#{compress_ctx => NewCompressCtx}};
{error, _Reason} ->
gateway_cluster_metrics:record_dispatch_drop(),
{ok, State}
end.
@@ -156,9 +158,11 @@ dispatch_standard(Event, Data, Seq, State) ->
},
case gateway_handler_encode:encode_and_compress(Message, State) of
{ok, Frame, NewState} ->
gateway_cluster_metrics:record_dispatch(),
trigger_gc_after_large_dispatch(Data),
{[Frame], NewState};
{error, _Reason} ->
gateway_cluster_metrics:record_dispatch_drop(),
{ok, State}
end.
@@ -158,6 +158,7 @@ finalize_resume(Pid, Seq, MissedEvents, GwTimings0, State) ->
),
ResumedData = put_resumed_gateway_timings(session_is_staff(Pid), GwTimings),
SocketPid ! {dispatch, resumed, ResumedData, Seq},
gateway_cluster_metrics:record_resume(),
erlang:garbage_collect(self(), [{type, major}]),
{ok, State#{
session_pid => Pid,
@@ -112,13 +112,17 @@ init([]) ->
slots => #{},
monitors => #{},
connecting => #{},
rpc_enabled => true,
rpc_enabled => nats_rpc_enabled(),
subs => #{},
handler_count => 0,
handler_refs => #{},
max_handlers => gateway_nats_pool_conn:max_handlers()
}}.
-spec nats_rpc_enabled() -> boolean().
nats_rpc_enabled() ->
fluxer_gateway_env:get(nats_rpc_enabled) =/= false.
-spec handle_call(term(), gen_server:from(), map()) -> {reply, term(), map()}.
handle_call(get_pool_status, _From, State) ->
{reply, build_pool_status(State), State};
@@ -571,6 +575,24 @@ legacy_connect_timeout_ignores_tokened_slot_worker_test() ->
exit(Pid, kill)
end.
pool_keeps_rpc_unsubscribed_when_switched_off_test() ->
persistent_term:put({fluxer_gateway, runtime_config}, #{nats_rpc_enabled => false}),
Parent = self(),
try
Pid = spawn(fun() -> Parent ! {self(), init([])} end),
{ok, State} =
receive
{Pid, Reply} -> Reply
after 5000 -> timeout
end,
?assertEqual(false, maps:get(rpc_enabled, State)),
?assertEqual(State, handle_ready(self(), false, #{0 => self()}, State))
after
persistent_term:erase(?PERSISTENT_TERM_KEY),
persistent_term:erase(?REPLY_FAILURE_KEY),
persistent_term:erase({fluxer_gateway, runtime_config})
end.
wait_forever() ->
receive
stop -> ok
@@ -169,7 +169,7 @@ do_subscribe(#{conn := Conn, rpc_enabled := RpcEnabled} = State) when
false ->
logger:info(
"Gateway NATS RPC connected but subscription disabled"
" (GATEWAY_NATS_RPC_ENABLED=false)"
" (FLUXER_GATEWAY_NATS_RPC_ENABLED=false)"
),
State;
_ ->
@@ -7,6 +7,8 @@
owner_node/1, owner_node/2,
owner_node_result/2,
select_owner_node/2,
select_guild_owner_node/2,
pinned_guild_owner/1,
active_nodes/0, active_nodes/1,
is_ready/0,
is_draining/0
@@ -30,11 +32,50 @@ owner_node(Key, Role) ->
-spec owner_node_result(term(), atom()) -> {ok, node()} | {error, {no_active_nodes, atom()}}.
owner_node_result(Key, Role) ->
case pinned_owner(Key, Role) of
{ok, Pinned} -> {ok, Pinned};
none -> hashed_owner_node_result(Key, Role)
end.
-spec hashed_owner_node_result(term(), atom()) ->
{ok, node()} | {error, {no_active_nodes, atom()}}.
hashed_owner_node_result(Key, Role) ->
case active_nodes(Role) of
[] -> fallback_owner_for_role(Role);
Nodes -> {ok, select_owner_node(Key, Nodes)}
end.
-spec select_guild_owner_node(term(), [node()]) -> node().
select_guild_owner_node(GuildId, Nodes) ->
case pinned_guild_owner(GuildId) of
{ok, Pinned} -> Pinned;
none -> select_owner_node(GuildId, Nodes)
end.
-spec pinned_owner(term(), atom()) -> {ok, node()} | none.
pinned_owner(Key, guilds) ->
pinned_guild_owner(Key);
pinned_owner(_Key, _Role) ->
none.
-spec pinned_guild_owner(term()) -> {ok, node()} | none.
pinned_guild_owner(GuildId) when is_integer(GuildId) ->
case application:get_env(fluxer_gateway, guild_owner_pins) of
{ok, #{GuildId := Node}} when is_atom(Node) -> live_pin(Node, nodes());
_ -> none
end;
pinned_guild_owner(_GuildId) ->
none.
-spec live_pin(node(), [node()]) -> {ok, node()} | none.
live_pin(Node, _Connected) when Node =:= node() ->
{ok, Node};
live_pin(Node, Connected) ->
case lists:member(Node, Connected) of
true -> {ok, Node};
false -> none
end.
-spec select_owner_node(term(), [node()]) -> node().
select_owner_node(_Key, []) ->
node();
@@ -67,13 +108,27 @@ role_active_nodes(Role, RoleMembers) when is_map(RoleMembers) ->
Monoliths0 = maps:get(all, RoleMembers, []),
Specific = filter_atoms(Specific0),
Monoliths = filter_atoms(Monoliths0),
intersect_current_members(lists:usort(Specific ++ Monoliths));
Active = intersect_current_members(lists:usort(Specific ++ Monoliths)),
exclude_pinned_only_nodes(Role, Active);
role_active_nodes(Role, _RoleMembers) ->
exclude_pinned_only_nodes(Role, local_role_nodes(Role)).
-spec local_role_nodes(atom()) -> [node()].
local_role_nodes(Role) ->
case fluxer_gateway_sup:role_enabled(Role) of
true -> [node()];
false -> []
end.
-spec exclude_pinned_only_nodes(atom(), [node()]) -> [node()].
exclude_pinned_only_nodes(guilds, Nodes) ->
case application:get_env(fluxer_gateway, guild_pinned_only_nodes) of
{ok, [_ | _] = Excluded} -> [Node || Node <- Nodes, not lists:member(Node, Excluded)];
_ -> Nodes
end;
exclude_pinned_only_nodes(_Role, Nodes) ->
Nodes.
-spec filter_atoms(term()) -> [atom()].
filter_atoms(List) when is_list(List) ->
[X || X <- List, is_atom(X)];
@@ -91,9 +146,9 @@ intersect_current_members(RoleNodes) ->
-spec fallback_owner_for_role(atom()) -> {ok, node()} | {error, {no_active_nodes, atom()}}.
fallback_owner_for_role(Role) ->
case fluxer_gateway_sup:role_enabled(Role) of
true -> {ok, node()};
false -> {error, {no_active_nodes, Role}}
case exclude_pinned_only_nodes(Role, local_role_nodes(Role)) of
[Self] -> {ok, Self};
_ -> {error, {no_active_nodes, Role}}
end.
-spec is_ready() -> boolean().
@@ -246,6 +301,99 @@ is_draining_reads_persistent_term_test() ->
?assert(is_draining()),
persistent_term:erase({fluxer_gateway, draining}).
-define(PINNED_GUILD, 1100000000000000001).
with_guild_members(Fun) ->
persistent_term:put(
{gateway_cluster_membership, members}, [node(), 'g1@h', 'g2@h', 'g3@h']
),
persistent_term:put({gateway_cluster_membership, members_by_role}, #{
guilds => [node(), 'g1@h', 'g2@h', 'g3@h'],
calls => ['g1@h', 'g2@h']
}),
try
Fun()
after
application:unset_env(fluxer_gateway, guild_owner_pins),
application:unset_env(fluxer_gateway, guild_pinned_only_nodes),
persistent_term:erase({gateway_cluster_membership, members}),
persistent_term:erase({gateway_cluster_membership, members_by_role})
end.
hashed_owners(Keys, Role) ->
Nodes = active_nodes(Role),
[{ok, select_owner_node(Key, Nodes)} || Key <- Keys].
unset_pins_keep_hashed_owners_test() ->
with_guild_members(fun() ->
Keys = [?PINNED_GUILD | lists:seq(1, 500)],
?assertEqual(hashed_owners(Keys, guilds), [owner_node_result(K, guilds) || K <- Keys]),
?assertEqual(hashed_owners(Keys, calls), [owner_node_result(K, calls) || K <- Keys])
end).
pin_to_self_applies_to_guilds_role_only_test() ->
with_guild_members(fun() ->
[Hashed] = hashed_owners([?PINNED_GUILD], calls),
Nodes = active_nodes(guilds),
Release = select_owner_node(?PINNED_GUILD, Nodes),
application:set_env(fluxer_gateway, guild_owner_pins, #{?PINNED_GUILD => node()}),
?assertEqual({ok, node()}, owner_node_result(?PINNED_GUILD, guilds)),
?assertEqual(Hashed, owner_node_result(?PINNED_GUILD, calls)),
?assertEqual(Release, select_owner_node(?PINNED_GUILD, Nodes)),
?assertEqual(hashed_owners([7], guilds), [owner_node_result(7, guilds)])
end).
pin_to_disconnected_node_falls_back_to_hashing_test() ->
with_guild_members(fun() ->
[Hashed] = hashed_owners([?PINNED_GUILD], guilds),
application:set_env(fluxer_gateway, guild_owner_pins, #{?PINNED_GUILD => 'pin_x@h'}),
?assertEqual(Hashed, owner_node_result(?PINNED_GUILD, guilds)),
?assertEqual(none, pinned_guild_owner(?PINNED_GUILD))
end).
live_pin_requires_a_connected_node_test() ->
?assertEqual({ok, 'pin_x@h'}, live_pin('pin_x@h', ['g1@h', 'pin_x@h'])),
?assertEqual(none, live_pin('pin_x@h', ['g1@h'])),
?assertEqual({ok, node()}, live_pin(node(), [])).
pinned_only_node_owns_no_hashed_guilds_test() ->
with_guild_members(fun() ->
Keys = lists:seq(1, 2000),
Before = [K || K <- Keys, owner_node_result(K, guilds) =:= {ok, node()}],
application:set_env(fluxer_gateway, guild_pinned_only_nodes, [node()]),
After = [K || K <- Keys, owner_node_result(K, guilds) =:= {ok, node()}],
?assertNotEqual([], Before),
?assertEqual([], After),
?assertEqual(['g1@h', 'g2@h', 'g3@h'], active_nodes(guilds)),
?assertEqual(['g1@h', 'g2@h'], active_nodes(calls))
end).
pinned_only_node_without_peers_owns_only_its_pins_test() ->
persistent_term:put({fluxer_gateway, runtime_config}, #{gateway_role => guilds}),
persistent_term:erase({gateway_cluster_membership, members_by_role}),
application:set_env(fluxer_gateway, guild_pinned_only_nodes, [node()]),
application:set_env(fluxer_gateway, guild_owner_pins, #{?PINNED_GUILD => node()}),
try
?assertEqual([], active_nodes(guilds)),
?assertEqual({error, {no_active_nodes, guilds}}, owner_node_result(5, guilds)),
?assertEqual({ok, node()}, owner_node_result(?PINNED_GUILD, guilds))
after
application:unset_env(fluxer_gateway, guild_owner_pins),
application:unset_env(fluxer_gateway, guild_pinned_only_nodes),
persistent_term:erase({fluxer_gateway, runtime_config})
end.
select_guild_owner_node_honours_live_pins_test() ->
with_guild_members(fun() ->
Targets = ['g1@h', 'g2@h'],
Hashed = select_owner_node(?PINNED_GUILD, Targets),
?assertEqual(Hashed, select_guild_owner_node(?PINNED_GUILD, Targets)),
application:set_env(fluxer_gateway, guild_owner_pins, #{?PINNED_GUILD => node()}),
?assertEqual(node(), select_guild_owner_node(?PINNED_GUILD, Targets)),
application:set_env(fluxer_gateway, guild_owner_pins, #{?PINNED_GUILD => 'pin_x@h'}),
?assertEqual(Hashed, select_guild_owner_node(?PINNED_GUILD, Targets))
end).
is_ready_inverts_is_draining_test() ->
persistent_term:erase({fluxer_gateway, draining}),
?assert(is_ready()),
@@ -89,9 +89,9 @@ gc_if_large_and_idle(Pid) ->
-spec gc_if_known_type(pid(), non_neg_integer()) -> ok.
gc_if_known_type(Pid, Memory) ->
try erlang:process_info(Pid, dictionary) of
{dictionary, Dict} ->
force_gc_by_type(Pid, Memory, proplists:get_value('$initial_call', Dict));
try erlang:process_info(Pid, {dictionary, '$initial_call'}) of
{{dictionary, '$initial_call'}, InitialCall} ->
force_gc_by_type(Pid, Memory, InitialCall);
_ ->
ok
catch
@@ -67,7 +67,7 @@ with_guild_unchecked(GuildId, Fun) ->
-spec ensure_responsive(pid()) -> ok.
ensure_responsive(Pid) ->
case guild_health:is_degraded(Pid) of
case guild_health:is_overloaded(Pid) of
true -> gateway_rpc_error:raise(<<"guild_overloaded">>);
false -> ok
end.
@@ -433,7 +433,8 @@ overloaded_guild_is_rejected_before_enqueuing_work_test() ->
stop -> ok
end
end),
true = ets:insert(guild_health_status, {Pid, 42, true, undefined, undefined}),
Pending = {make_ref(), erlang:monotonic_time(millisecond), 2500},
true = ets:insert(guild_health_status, {Pid, 42, true, undefined, Pending}),
try
?assertError(
{gateway_rpc_error, <<"guild_overloaded">>},
@@ -30,6 +30,7 @@ get_local_node_id() ->
-spec get_local_node_stats() -> map().
get_local_node_stats() ->
SessionCount = get_manager_count(session_manager),
ResumeCount = gateway_cluster_metrics:resumes_total(),
GuildCount = get_manager_count(guild_manager),
PresenceCount = get_manager_count(presence_manager),
CallCount = get_manager_count(call_manager),
@@ -41,6 +42,9 @@ get_local_node_stats() ->
<<"node_id">> => node_id(node()),
<<"status">> => <<"healthy">>,
<<"sessions">> => SessionCount,
<<"session_resumes_total">> => ResumeCount,
<<"websocket_dispatches_total">> => gateway_cluster_metrics:dispatches_total(),
<<"websocket_dispatch_drops_total">> => gateway_cluster_metrics:dispatch_drops_total(),
<<"guilds">> => GuildCount,
<<"presences">> => PresenceCount,
<<"calls">> => CallCount,
@@ -180,6 +184,13 @@ aggregate_node_stats(NodeStats) ->
#{
<<"status">> => aggregate_status(SortedNodes),
<<"sessions">> => sum_stat(SortedNodes, <<"sessions">>),
<<"session_resumes_total">> => sum_stat(SortedNodes, <<"session_resumes_total">>),
<<"websocket_dispatches_total">> => sum_stat(
SortedNodes, <<"websocket_dispatches_total">>
),
<<"websocket_dispatch_drops_total">> => sum_stat(
SortedNodes, <<"websocket_dispatch_drops_total">>
),
<<"guilds">> => sum_stat(SortedNodes, <<"guilds">>),
<<"presences">> => sum_stat(SortedNodes, <<"presences">>),
<<"calls">> => sum_stat(SortedNodes, <<"calls">>),
@@ -266,6 +277,9 @@ unavailable_node_stats(TargetNode) ->
<<"node_id">> => node_id(TargetNode),
<<"status">> => <<"unavailable">>,
<<"sessions">> => null,
<<"session_resumes_total">> => null,
<<"websocket_dispatches_total">> => null,
<<"websocket_dispatch_drops_total">> => null,
<<"guilds">> => null,
<<"presences">> => null,
<<"calls">> => null,
@@ -315,6 +329,9 @@ aggregate_node_stats_sums_cluster_totals_test() ->
<<"node_id">> => <<"[email protected]">>,
<<"status">> => <<"healthy">>,
<<"sessions">> => 10,
<<"session_resumes_total">> => 1,
<<"websocket_dispatches_total">> => 7,
<<"websocket_dispatch_drops_total">> => 7,
<<"guilds">> => 20,
<<"presences">> => 30,
<<"calls">> => 40,
@@ -327,6 +344,9 @@ aggregate_node_stats_sums_cluster_totals_test() ->
<<"node_id">> => <<"[email protected]">>,
<<"status">> => <<"healthy">>,
<<"sessions">> => 3,
<<"session_resumes_total">> => 1,
<<"websocket_dispatches_total">> => 7,
<<"websocket_dispatch_drops_total">> => 7,
<<"guilds">> => 4,
<<"presences">> => 5,
<<"calls">> => 6,
@@ -336,7 +356,10 @@ aggregate_node_stats_sums_cluster_totals_test() ->
<<"uptime_seconds">> => 90
},
A = aggregate_node_stats([N1, N2]),
?assertEqual(14, maps:get(<<"websocket_dispatches_total">>, A)),
?assertEqual(14, maps:get(<<"websocket_dispatch_drops_total">>, A)),
?assertEqual(13, maps:get(<<"sessions">>, A)),
?assertEqual(2, maps:get(<<"session_resumes_total">>, A)),
?assertEqual(24, maps:get(<<"guilds">>, A)),
?assertEqual(35, maps:get(<<"presences">>, A)),
?assertEqual(46, maps:get(<<"calls">>, A)),
@@ -51,6 +51,9 @@ render_metrics() ->
-spec render_gateway_gauges() -> iolist().
render_gateway_gauges() ->
Sessions = safe_apply_int(fun session_manager:session_count/0),
DispatchDrops = safe_apply_int(fun gateway_cluster_metrics:dispatch_drops_total/0),
Dispatches = safe_apply_int(fun gateway_cluster_metrics:dispatches_total/0),
Resumes = safe_apply_int(fun gateway_cluster_metrics:resumes_total/0),
Guilds = safe_apply_int(fun guild_manager:local_guild_count/0),
VoiceCounts = safe_apply_map(fun voice_state_counts_cache:get_local_counts/0),
CallIds = safe_apply_list(fun call_manager:local_call_ids/0),
@@ -64,12 +67,30 @@ render_gateway_gauges() ->
<<"Connected WebSocket sessions">>,
integer_to_binary(Sessions)
),
format_metric(
<<"fluxer_gateway_session_resumes_total">>,
<<"counter">>,
<<"Successful WebSocket session resumes">>,
integer_to_binary(Resumes)
),
format_metric(
<<"fluxer_gateway_guilds_total">>,
<<"gauge">>,
<<"Locally loaded guilds">>,
integer_to_binary(Guilds)
),
format_metric(
<<"fluxer_gateway_websocket_dispatches_total">>,
<<"counter">>,
<<"WebSocket dispatch frames handed to the transport, including replays">>,
integer_to_binary(Dispatches)
),
format_metric(
<<"fluxer_gateway_websocket_dispatch_drops_total">>,
<<"counter">>,
<<"WebSocket dispatches dropped due to encoding or compression errors">>,
integer_to_binary(DispatchDrops)
),
render_voice_metrics(VoiceCounts),
format_metric(
<<"fluxer_gateway_calls_total">>,
+10
View File
@@ -49,18 +49,24 @@ init(GuildState) ->
-spec handle_call(term(), gen_server:from(), guild_state()) -> call_reply().
handle_call(Msg, From, State) ->
ok = guild_mailbox_age:note(),
Result = handle_call_internal(Msg, From, State),
ok = publish_read_model(Result, State),
Result.
-spec handle_cast(term(), guild_state()) -> cast_reply().
handle_cast(Msg, State) ->
ok = guild_mailbox_age:note(),
Result = handle_cast_internal(Msg, State),
ok = publish_read_model(Result, State),
Result.
-spec handle_info(term(), guild_state()) -> info_reply().
handle_info({guild_mailbox_age, Seq}, State) when is_integer(Seq), Seq >= 0 ->
ok = guild_mailbox_age:handle_mark(Seq),
{noreply, State};
handle_info(Msg, State) ->
ok = guild_mailbox_age:note(),
Result = handle_info_internal(Msg, State),
ok = publish_read_model(Result, State),
Result.
@@ -188,6 +194,10 @@ handle_cast_internal(
{session_connect_worker_done, SessionId, Attempt, Result0, Computed}, State
) ->
handle_session_connect_worker_done_cast(SessionId, Attempt, Result0, Computed, State);
handle_cast_internal({session_connect_worker_batch_done, Results}, State) when
is_list(Results)
->
{noreply, guild_connect_async:finalize_session_connect_batch(Results, State)};
handle_cast_internal({set_session_active, SessionId}, State) ->
handle_set_session_active_cast(SessionId, State);
handle_cast_internal({set_session_passive, SessionId}, State) ->
+209 -85
View File
@@ -6,6 +6,7 @@
-export([
enqueue_session_connect_async/5,
finalize_session_connect_async/5,
finalize_session_connect_batch/2,
maybe_start_session_connect_workers/1,
decrement_session_connect_inflight/1,
ensure_session_connect_queue/1
@@ -13,22 +14,23 @@
-type session_id() :: binary().
-type guild_id() :: integer().
-export_type([session_id/0]).
-type user_id() :: integer().
-type connect_result() :: {session_id() | undefined, non_neg_integer(), term(), map()}.
-type joined() :: {#{user_id() => map()}, [user_id()]}.
-export_type([session_id/0, connect_result/0]).
-define(SESSION_CONNECT_MAX_WORKERS, 8).
-define(SESSION_CONNECT_DEFAULT_MAX_QUEUE, 1024).
-define(SESSION_CONNECT_MAX_BATCH, 8).
-define(SESSION_CONNECT_DEFAULT_MAX_QUEUE, 8192).
-spec ensure_session_connect_queue(term()) -> queue:queue().
ensure_session_connect_queue(Value) when is_list(Value) ->
queue:from_list(Value);
ensure_session_connect_queue(Value) ->
safe_queue_cast(Value).
-spec safe_queue_cast(term()) -> queue:queue().
safe_queue_cast({R, F} = _Value) when is_list(R), is_list(F) ->
queue:from_list(F ++ lists:reverse(R));
safe_queue_cast(_) ->
queue:new().
case queue:is_queue(Value) of
true -> Value;
false -> queue:new()
end.
-spec enqueue_session_connect_async(integer(), non_neg_integer(), map(), map(), map()) -> map().
enqueue_session_connect_async(GuildId, Attempt, Request, Msg, State) ->
@@ -70,60 +72,114 @@ enqueue_session_connect_async_for_session(GuildId, Attempt, Request, Msg, Sessio
map(),
map()
) -> map().
finalize_session_connect_async(undefined, _Attempt, _Result0, _Computed, State) ->
maybe_start_session_connect_workers(decrement_session_connect_inflight(State));
finalize_session_connect_async(SessionId, Attempt, Result0, Computed, State) ->
State1 = decrement_session_connect_inflight(State),
Pending0 = maps:get(session_connect_pending, State1, #{}),
case maps:find(SessionId, Pending0) of
{ok, Attempt} ->
State2 = State1#{session_connect_pending => maps:remove(SessionId, Pending0)},
finalize_matched(SessionId, Attempt, Result0, Computed, State2);
_ ->
maybe_start_session_connect_workers(State1)
end.
finalize_session_connect_batch([{SessionId, Attempt, Result0, Computed}], State).
-spec finalize_matched(session_id(), non_neg_integer(), term(), map(), map()) -> map().
finalize_matched(SessionId, Attempt, Result0, Computed, State) ->
-spec finalize_session_connect_batch([connect_result()], map()) -> map().
finalize_session_connect_batch(Results, State) ->
State1 = decrement_session_connect_inflight(State),
{State2, Joined} = lists:foldl(fun finalize_result/2, {State1, {#{}, []}}, Results),
maybe_start_session_connect_workers(finish_joined_users(Joined, State1, State2)).
-spec finalize_result(term(), {map(), joined()}) -> {map(), joined()}.
finalize_result({SessionId, Attempt, Result0, Computed}, {State, Joined}) when
is_binary(SessionId), is_integer(Attempt), is_map(Computed)
->
Pending0 = maps:get(session_connect_pending, State, #{}),
case {maps:find(SessionId, Pending0), valid_result(Result0)} of
{{ok, Attempt}, true} ->
State1 = State#{session_connect_pending => maps:remove(SessionId, Pending0)},
finalize_matched(SessionId, Attempt, Result0, Computed, State1, Joined);
_ ->
{State, Joined}
end;
finalize_result(_Result, Acc) ->
Acc.
-spec valid_result(term()) -> boolean().
valid_result({ok, Result}) -> is_map(Result);
valid_result({ok_unavailable, Result}) -> is_map(Result);
valid_result({error, _Reason}) -> true;
valid_result(_) -> false.
-spec finalize_matched(session_id(), non_neg_integer(), term(), map(), map(), joined()) ->
{map(), joined()}.
finalize_matched(SessionId, Attempt, Result0, Computed, State, Joined) ->
Request = maps:get(request, Computed, #{}),
SessionPid = maps:get(session_pid, Request, undefined),
case is_pid(SessionPid) of
false ->
maybe_start_session_connect_workers(State);
{State, Joined};
true ->
finalize_with_pid(SessionId, Attempt, Result0, Computed, Request, SessionPid, State)
finalize_with_pid(
SessionId, Attempt, Result0, Computed, Request, SessionPid, State, Joined
)
end.
-spec finalize_with_pid(session_id(), non_neg_integer(), term(), map(), map(), pid(), map()) ->
map().
finalize_with_pid(SessionId, Attempt, Result0, Computed, Request, SessionPid, State) ->
-spec finalize_with_pid(
session_id(), non_neg_integer(), term(), map(), map(), pid(), map(), joined()
) -> {map(), joined()}.
finalize_with_pid(SessionId, Attempt, Result0, Computed, Request, SessionPid, State, Joined) ->
case resolve_guild_id(State, Computed) of
GuildId when is_integer(GuildId), GuildId > 0 ->
finalize_resolved(
GuildId, SessionId, Attempt, Result0, Computed, Request, SessionPid, State
GuildId,
SessionId,
Attempt,
Result0,
Computed,
Request,
SessionPid,
State,
Joined
);
_ ->
maybe_start_session_connect_workers(State)
{State, Joined}
end.
-spec finalize_resolved(
integer(), session_id(), non_neg_integer(), term(), map(), map(), pid(), map()
) -> map().
integer(), session_id(), non_neg_integer(), term(), map(), map(), pid(), map(), joined()
) -> {map(), joined()}.
finalize_resolved(
GuildId, SessionId, Attempt, {error, _} = Result0, _Computed, _Request, SessionPid, State
GuildId,
SessionId,
Attempt,
{error, Reason} = Result0,
_Computed,
_Request,
SessionPid,
State,
Joined
) ->
State1 = discard_pending_session(SessionId, State),
State1 = discard_pending_session(SessionId, Reason, State),
send_result(GuildId, Attempt, Result0, SessionPid),
maybe_start_session_connect_workers(State1);
finalize_resolved(GuildId, SessionId, Attempt, Result0, Computed, Request, SessionPid, State) ->
State1 = upsert_session(SessionId, SessionPid, Request, Computed, State),
{State1, Joined};
finalize_resolved(
GuildId, SessionId, Attempt, Result0, Computed, Request, SessionPid, State, Joined
) ->
Acc = upsert_session(SessionId, SessionPid, Request, Computed, State, Joined),
send_result(GuildId, Attempt, Result0, SessionPid),
maybe_start_session_connect_workers(State1).
Acc.
-spec discard_pending_session(session_id() | undefined, map()) -> map().
discard_pending_session(SessionId, State) when is_binary(SessionId) ->
-spec finish_joined_users(joined(), map(), map()) -> map().
finish_joined_users({Joined, Fresh}, StartState, State) ->
State1 = guild_presence:apply_connect_presences(lists:reverse(Fresh), State),
maps:fold(
fun(UserId, PresenceBefore, Acc) ->
guild_sessions_connect:resection_connected_user(
UserId, PresenceBefore, StartState, Acc
)
end,
State1,
Joined
).
-spec discard_pending_session(session_id(), term(), map()) -> map().
discard_pending_session(SessionId, Reason, State) ->
Sessions0 = maps:get(sessions, State, #{}),
case maps:find(SessionId, Sessions0) of
{ok, #{pending_connect := true, owns_connected_tracking := true} = Entry} ->
discard_reconnecting_session(SessionId, Reason, Entry, State);
{ok, #{pending_connect := true} = Entry} ->
demonitor_pending_session(Entry),
guild_sessions_connect:remove_session_ref(
@@ -132,8 +188,18 @@ discard_pending_session(SessionId, State) when is_binary(SessionId) ->
);
_ ->
State
end;
discard_pending_session(_SessionId, State) ->
end.
-spec discard_reconnecting_session(session_id(), term(), map(), map()) -> map().
discard_reconnecting_session(SessionId, not_member, #{mref := MRef} = Entry, State) when
is_reference(MRef)
->
demonitor(MRef, [flush]),
{noreply, State1} = guild_sessions_connect:handle_session_down(
MRef, {SessionId, Entry}, State
),
State1;
discard_reconnecting_session(_SessionId, _Reason, _Entry, State) ->
State.
-spec demonitor_pending_session(map()) -> ok.
@@ -158,19 +224,31 @@ maybe_start_session_connect_workers(State) ->
-spec dequeue_and_start(queue:queue(), non_neg_integer(), map()) -> map().
dequeue_and_start(Queue0, Inflight0, State) ->
case queue:out(Queue0) of
{{value, Next}, Rest} when is_map(Next) ->
case take_batch(batch_size(Queue0, Inflight0), Queue0, []) of
{[], Rest} ->
State#{session_connect_queue => Rest};
{Items, Rest} ->
State1 = State#{
session_connect_queue => Rest,
session_connect_inflight => Inflight0 + 1
},
maybe_start_session_connect_workers(start_worker(Next, State1));
{{value, _Next}, Rest} ->
maybe_start_session_connect_workers(
State#{session_connect_queue => Rest}
);
{empty, _} ->
State#{session_connect_queue => Queue0}
maybe_start_session_connect_workers(start_worker(Items, State1))
end.
-spec batch_size(queue:queue(), non_neg_integer()) -> pos_integer().
batch_size(Queue, Inflight) ->
FreeWorkers = max(1, ?SESSION_CONNECT_MAX_WORKERS - Inflight),
Spread = (queue:len(Queue) + FreeWorkers - 1) div FreeWorkers,
max(1, min(?SESSION_CONNECT_MAX_BATCH, Spread)).
-spec take_batch(non_neg_integer(), queue:queue(), [map()]) -> {[map()], queue:queue()}.
take_batch(0, Queue, Acc) ->
{lists:reverse(Acc), Queue};
take_batch(N, Queue, Acc) ->
case queue:out(Queue) of
{{value, Item}, Rest} when is_map(Item) -> take_batch(N - 1, Rest, [Item | Acc]);
{{value, _Item}, Rest} -> take_batch(N, Rest, Acc);
{empty, Rest} -> {lists:reverse(Acc), Rest}
end.
-spec decrement_session_connect_inflight(map()) -> map().
@@ -280,7 +358,7 @@ cleanup_dropped_session_connect(Item, State) ->
case queued_session_id(Item) of
SessionId when is_binary(SessionId) ->
State1 = remove_pending_session_connect(SessionId, State),
discard_pending_session(SessionId, State1);
discard_pending_session(SessionId, overloaded, State1);
_ ->
State
end.
@@ -316,14 +394,22 @@ upsert_pending_session(S, U, P, Request, State) ->
active_guilds => maps:get(active_guilds, Request, sets:new()),
bot => maps:get(bot, Request, false),
is_staff => maps:get(is_staff, Request, false),
pending_connect => true,
viewable_channels => #{}
pending_connect => true
},
guild_sessions_connect:put_session_ref(S, MRef, State#{
sessions => Sessions0#{S => Entry}
});
{ok, Existing} ->
State#{sessions => Sessions0#{S => Existing#{pending_connect => true}}}
State#{sessions => Sessions0#{S => mark_pending(Existing)}}
end.
-spec mark_pending(map()) -> map().
mark_pending(#{pending_connect := true} = Existing) ->
Existing;
mark_pending(Existing) ->
case connected_user_id_from_existing(Existing) of
undefined -> Existing#{pending_connect => true};
_UserId -> Existing#{pending_connect => true, owns_connected_tracking => true}
end.
-spec drop_queued(session_id(), queue:queue()) -> queue:queue().
@@ -364,24 +450,30 @@ queued_session_pid(#{reply_via_pid := SessionPid}) when is_pid(SessionPid) ->
queued_session_pid(_) ->
undefined.
-spec start_worker(map(), map()) -> map().
start_worker(Item, State) ->
-spec start_worker([map()], map()) -> map().
start_worker(Items, State) ->
Self = self(),
Snapshot = guild_data:build_connect_snapshot(Item, State),
{_Pid, Ref} = spawn_monitor(fun() -> compute_and_send_done(Item, Self, Snapshot) end),
Snapshot = guild_data:build_connect_snapshot(Items, State),
{_Pid, Ref} = spawn_monitor(fun() -> compute_and_send_done(Items, Self, Snapshot) end),
WorkerRefs = maps:get(session_connect_worker_refs, State, #{}),
State#{session_connect_worker_refs => WorkerRefs#{Ref => true}}.
-spec compute_and_send_done(map(), pid(), map()) -> ok.
compute_and_send_done(Item, GuildPid, Snapshot) ->
-spec compute_and_send_done([map()], pid(), map()) -> ok.
compute_and_send_done(Items, GuildPid, Snapshot) ->
Results = [compute_item(Item, Snapshot) || Item <- Items],
gen_server:cast(GuildPid, {session_connect_worker_batch_done, Results}),
ok.
-spec compute_item(map(), map()) -> connect_result().
compute_item(Item, Snapshot) ->
Request = maps:get(request, Item, #{}),
{Result0, Computed0} = compute_connect_result(Snapshot, Item, Request),
gen_server:cast(
GuildPid,
{session_connect_worker_done, maps:get(session_id, Request, undefined),
maps:get(attempt, Item, 0), Result0, maps:merge(Item, Computed0)}
),
ok.
{
maps:get(session_id, Request, undefined),
maps:get(attempt, Item, 0),
Result0,
maps:merge(Item, Computed0)
}.
-spec compute_connect_result(map(), map(), map()) ->
{{ok, map()} | {ok_unavailable, map()} | {error, term()}, map()}.
@@ -471,16 +563,21 @@ resolve_guild_id(State, Fallback) ->
snowflake_id:parse_optional(maps:get(guild_id, Fallback, undefined))
end.
-spec upsert_session(session_id(), pid(), map(), map(), map()) -> map().
upsert_session(SessionId, SessionPid, Request, Computed, State) ->
-spec upsert_session(session_id(), pid(), map(), map(), map(), joined()) -> {map(), joined()}.
upsert_session(SessionId, SessionPid, Request, Computed, State, Joined) ->
UserId = maps:get(user_id, Request, undefined),
case is_integer(UserId) of
false -> State;
true -> upsert_session_valid(SessionId, SessionPid, UserId, Request, Computed, State)
false ->
{State, Joined};
true ->
upsert_session_valid(
SessionId, SessionPid, UserId, Request, Computed, State, Joined
)
end.
-spec upsert_session_valid(session_id(), pid(), integer(), map(), map(), map()) -> map().
upsert_session_valid(SessionId, SessionPid, UserId, Request, Computed, State) ->
-spec upsert_session_valid(session_id(), pid(), integer(), map(), map(), map(), joined()) ->
{map(), joined()}.
upsert_session_valid(SessionId, SessionPid, UserId, Request, Computed, State, Joined) ->
Sessions0 = maps:get(sessions, State, #{}),
Existing = maps:get(SessionId, Sessions0, undefined),
{MRef, Existing1} = resolve_monitor(Existing, SessionPid),
@@ -491,9 +588,21 @@ upsert_session_valid(SessionId, SessionPid, UserId, Request, Computed, State) ->
Sessions = merge_session(SessionId, FinalSD, Existing1, Sessions0),
State1 = reindex_session_ref(SessionId, Existing, MRef, State#{sessions => Sessions}),
State2 = update_connected_tracking(UserId, Existing, State1),
PresenceBefore = guild_member_list_connected:resolve_presence_for_user(State2, UserId),
State3 = update_presence_subscription(UserId, Existing, State2),
guild_sessions_connect:resection_connected_user(UserId, PresenceBefore, State, State3).
update_presence_subscription(
UserId, Existing, State2, note_joined_user(UserId, State2, Joined)
).
-spec note_joined_user(integer(), map(), joined()) -> joined().
note_joined_user(UserId, State, {Joined, Fresh}) ->
case maps:is_key(UserId, Joined) of
true ->
{Joined, Fresh};
false ->
PresenceBefore = guild_member_list_connected:resolve_presence_for_user(
State, UserId
),
{Joined#{UserId => PresenceBefore}, Fresh}
end.
-spec build_session_data(session_id(), integer(), pid(), reference(), map(), map()) -> map().
build_session_data(SessionId, UserId, SessionPid, MRef, Request, Computed) ->
@@ -530,7 +639,9 @@ maybe_mark_synced(GuildId, Computed, SessionData) ->
merge_session(SessionId, FinalSD, undefined, Sessions0) ->
Sessions0#{SessionId => FinalSD};
merge_session(SessionId, FinalSD, Existing, Sessions0) ->
Sessions0#{SessionId => maps:merge(Existing, FinalSD)}.
Sessions0#{
SessionId => maps:merge(maps:remove(owns_connected_tracking, Existing), FinalSD)
}.
-spec reindex_session_ref(session_id(), map() | undefined, reference(), map()) -> map().
reindex_session_ref(SessionId, #{mref := OldRef}, MRef, State) when OldRef =/= MRef ->
@@ -563,26 +674,37 @@ update_connected_tracking(UserId, Existing, State) ->
add_connected_user(UserId, remove_connected_user(ExistingUserId, State))
end.
-spec update_presence_subscription(integer(), map() | undefined, map()) -> map().
update_presence_subscription(UserId, Existing, State) ->
-spec update_presence_subscription(integer(), map() | undefined, map(), joined()) ->
{map(), joined()}.
update_presence_subscription(UserId, Existing, State, Joined) ->
case connected_user_id_from_existing(Existing) of
UserId ->
State;
{State, Joined};
undefined ->
guild_sessions:subscribe_connected_user_presence(UserId, State);
subscribe_joined_user(UserId, State, Joined);
ExistingUserId ->
State1 = guild_sessions:unsubscribe_from_user_presence(ExistingUserId, State),
guild_sessions:subscribe_connected_user_presence(UserId, State1)
subscribe_joined_user(UserId, State1, Joined)
end.
-spec subscribe_joined_user(integer(), map(), joined()) -> {map(), joined()}.
subscribe_joined_user(UserId, State, {Joined, Fresh}) ->
case guild_sessions_presence:subscribe_without_cached_presence(UserId, State) of
{fresh, State1} -> {State1, {Joined, [UserId | Fresh]}};
{existing, State1} -> {State1, {Joined, Fresh}}
end.
-spec connected_user_id_from_existing(map() | undefined) -> integer() | undefined.
connected_user_id_from_existing(undefined) ->
undefined;
connected_user_id_from_existing(#{pending_connect := true}) ->
undefined;
connected_user_id_from_existing(Existing) ->
case maps:get(user_id, Existing, undefined) of
UserId when is_integer(UserId), UserId > 0 ->
case
{
guild_sessions_connect:counts_as_connected(Existing),
maps:get(user_id, Existing, undefined)
}
of
{true, UserId} when is_integer(UserId), UserId > 0 ->
UserId;
_ ->
undefined
@@ -703,6 +825,7 @@ enqueue_marks_existing_session_pending_connect_test() ->
SessionId, UserId, pending_connect_state(#{SessionId => Existing})
),
?assertEqual(true, maps:get(pending_connect, Entry)),
?assertEqual(true, maps:get(owns_connected_tracking, Entry)),
?assertEqual(MRef, maps:get(mref, Entry)),
?assertEqual(UserId, maps:get(user_id, Entry)).
@@ -712,6 +835,7 @@ enqueue_creates_pending_session_entry_test() ->
Entry = enqueued_session_entry(SessionId, UserId, pending_connect_state(#{})),
demonitor(maps:get(mref, Entry), [flush]),
?assertEqual(true, maps:get(pending_connect, Entry)),
?assertNot(maps:is_key(owns_connected_tracking, Entry)),
?assertEqual(UserId, maps:get(user_id, Entry)).
-endif.
+342
View File
@@ -0,0 +1,342 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(guild_cutover).
-feature(maybe_expr, enable).
-typing([eqwalizer]).
-export([run/1, reverse/1, move/4, routes/2]).
-define(PIN_KEY, guild_owner_pins).
-define(DEFAULT_ROUTE_TIMEOUT, 5000).
-define(DEFAULT_RELOAD_TIMEOUT, 120000).
-define(DEFAULT_MAX_MAILBOX, 100).
-define(PROBE_KEY, 0).
-define(KEEPER_KEYS, [guild_pin_keeper_armed, guild_pin_keeper_ever_armed]).
-type guild_id() :: integer().
-type saved_pins() :: #{node() => {ok, term()} | undefined}.
-type report() :: map().
-export_type([report/0]).
-spec run(map()) -> {ok, report()} | {error, report()}.
run(#{guild_id := GuildId, source_node := SourceNode} = Opts) when
is_integer(GuildId), is_atom(SourceNode)
->
move(GuildId, SourceNode, node(), maps:merge(#{transfer_sessions => true}, Opts)).
-spec reverse(map()) -> {ok, report()} | {error, report()}.
reverse(#{guild_id := GuildId, target_node := TargetNode} = Opts) when
is_integer(GuildId), is_atom(TargetNode)
->
move(GuildId, node(), TargetNode, maps:merge(#{transfer_sessions => false}, Opts)).
-spec routes(guild_id(), [node()]) -> #{node() => term()}.
routes(GuildId, Nodes) ->
Args = [GuildId, guilds],
Results = erpc:multicall(
Nodes, gateway_node_router, owner_node_result, Args, ?DEFAULT_ROUTE_TIMEOUT
),
maps:from_list(lists:zip(Nodes, Results)).
-spec move(guild_id(), node(), node(), map()) -> {ok, report()} | {error, report()}.
move(GuildId, From, To, Opts) ->
Nodes = route_nodes(From, To, Opts),
case preflight(GuildId, From, To, Nodes, Opts) of
{ok, SrcPid, SrcShard, Saved} ->
TransferOpts = transfer_opts(GuildId, From, To, Nodes, Saved, Opts),
Result = guild_handoff_freeze:transfer(GuildId, SrcPid, SrcShard, To, TransferOpts),
finish(GuildId, SrcPid, {From, To, Nodes}, Result, Opts);
{error, Reason} ->
{error, #{phase => preflight, reason => Reason, from => From, to => To}}
end.
-spec route_nodes(node(), node(), map()) -> [node()].
route_nodes(From, To, Opts) ->
Nodes = maps:get(route_nodes, Opts, [node() | nodes()]),
lists:usort([From, To | [N || N <- Nodes, is_atom(N)]]).
-spec preflight(guild_id(), node(), node(), [node()], map()) ->
{ok, pid(), pid(), saved_pins()} | {error, term()}.
preflight(_GuildId, Node, Node, _Nodes, _Opts) ->
{error, same_node};
preflight(GuildId, From, To, Nodes, Opts) ->
maybe
ok ?= keeper_idle(lists:usort([node(), From, To])),
ok ?= connected([From, To]),
ok ?= expect_routes(GuildId, Nodes, From),
{ok, SrcShard} ?= guild_handoff_freeze:shard_pid(GuildId, From),
{ok, SrcPid} ?= lookup(SrcShard, GuildId),
ok ?= absent_on(GuildId, To),
ok ?= mailbox_below(SrcPid, maps:get(max_mailbox, Opts, ?DEFAULT_MAX_MAILBOX)),
false ?= guild_handoff_freeze:is_frozen(SrcPid),
{ok, Saved} ?= read_pins(Nodes),
ok ?= probe_pins(Nodes, To, Saved),
{ok, SrcPid, SrcShard, Saved}
else
true -> {error, source_already_frozen};
{error, _} = Error -> Error
end.
-spec transfer_opts(guild_id(), node(), node(), [node()], saved_pins(), map()) ->
guild_handoff_freeze:opts().
transfer_opts(GuildId, From, To, Nodes, Saved, Opts) ->
Base = maps:with(
[
suspend_timeout,
export_timeout,
start_timeout,
stop_timeout,
barrier_timeout,
rpc_timeout,
guard_timeout,
freeze_budget,
commit_timeout,
forward_rounds,
transfer_sessions,
max_heap_words,
measure
],
Opts
),
Others = [N || N <- Nodes, N =/= To],
Base#{
before_start => fun() -> pin_and_verify([To], GuildId, To, Saved) end,
after_start => fun(_NewPid) -> pin_and_verify(Others, GuildId, To, Saved) end,
on_abort => fun() -> restore_and_verify(Nodes, GuildId, From, Saved) end
}.
-spec finish(
guild_id(), pid(), {node(), node(), [node()]}, guild_handoff_freeze:result(), map()
) ->
{ok, report()} | {error, report()}.
finish(GuildId, _SrcPid, {From, To, Nodes}, {ok, Report}, Opts) ->
Reload = maybe_reload(GuildId, To, Opts),
{ok, Report#{
from => From,
to => To,
reload => Reload,
routes_after => routes(GuildId, Nodes)
}};
finish(GuildId, SrcPid, {From, To, Nodes}, {error, Report}, Opts) ->
Repair = repair_source(GuildId, SrcPid, Report, Opts),
{error, Report#{
from => From,
to => To,
source_reload => Repair,
routes_after => routes(GuildId, Nodes)
}}.
-spec repair_source(guild_id(), pid(), report(), map()) -> skipped | term().
repair_source(GuildId, SrcPid, #{abort := #{routes := Routes} = Abort}, Opts) when
Routes =/= skipped
->
case maps:get(exposed, Abort, true) of
true -> guild_handoff_freeze:repair(GuildId, SrcPid, repair_opts(Opts));
false -> skipped
end;
repair_source(_GuildId, _SrcPid, _Report, _Opts) ->
skipped.
-spec repair_opts(map()) -> guild_handoff_freeze:opts().
repair_opts(Opts) ->
maps:with([suspend_timeout, guard_timeout, repair_timeout], Opts).
-spec maybe_reload(guild_id(), node(), map()) -> skipped | term().
maybe_reload(GuildId, To, #{reload := true} = Opts) ->
reload(GuildId, To, Opts);
maybe_reload(_GuildId, _To, _Opts) ->
skipped.
-spec reload(guild_id(), node(), map()) -> term().
reload(GuildId, Node, Opts) ->
Timeout = maps:get(reload_timeout, Opts, ?DEFAULT_RELOAD_TIMEOUT),
Args = [guild_manager, {reload_guild, GuildId}, Timeout],
try erpc:call(Node, gen_server, call, Args, Timeout + 5000) of
Reply -> Reply
catch
Class:Reason -> {error, {Class, Reason}}
end.
-spec keeper_idle([node()]) -> ok | {error, term()}.
keeper_idle(Nodes) ->
Answers = [
{Node, Key, Answer}
|| Key <- ?KEEPER_KEYS,
{Node, Answer} <- lists:zip(
Nodes,
erpc:multicall(
Nodes, application, get_env, [fluxer_gateway, Key], ?DEFAULT_ROUTE_TIMEOUT
)
)
],
Active = [{Node, Key} || {Node, Key, {ok, {ok, true}}} <- Answers],
Unreadable = [{Node, Key, A} || {Node, Key, A} <- Answers, not keeper_answer(A)],
case {Active, Unreadable} of
{[], []} -> ok;
{[], _} -> {error, {pin_keeper_unreadable, Unreadable}};
_ -> {error, {pin_keeper_active, Active}}
end.
-spec keeper_answer(term()) -> boolean().
keeper_answer({ok, undefined}) -> true;
keeper_answer({ok, {ok, _Value}}) -> true;
keeper_answer(_Answer) -> false.
-spec connected([node()]) -> ok | {error, term()}.
connected(Nodes) ->
case [N || N <- Nodes, N =/= node(), not lists:member(N, nodes())] of
[] -> ok;
Missing -> {error, {not_connected, Missing}}
end.
-spec expect_routes(guild_id(), [node()], node()) -> ok | {error, term()}.
expect_routes(GuildId, Nodes, Owner) ->
case [{N, R} || {N, R} <- maps:to_list(routes(GuildId, Nodes)), R =/= {ok, {ok, Owner}}] of
[] -> ok;
Wrong -> {error, {routes_disagree, Owner, Wrong}}
end.
-spec lookup(pid(), guild_id()) -> {ok, pid()} | {error, term()}.
lookup(Shard, GuildId) ->
try gen_server:call(Shard, {lookup, GuildId}, ?DEFAULT_ROUTE_TIMEOUT) of
{ok, Pid} when is_pid(Pid) -> {ok, Pid};
Other -> {error, {source_lookup, Other}}
catch
exit:Reason -> {error, {source_lookup, Reason}}
end.
-spec absent_on(guild_id(), node()) -> ok | {error, term()}.
absent_on(GuildId, Node) ->
case guild_handoff_freeze:shard_pid(GuildId, Node) of
{ok, Shard} ->
try gen_server:call(Shard, {lookup, GuildId}, ?DEFAULT_ROUTE_TIMEOUT) of
{error, not_found} -> ok;
Other -> {error, {target_not_empty, Other}}
catch
exit:Reason -> {error, {target_lookup, Reason}}
end;
{error, _} = Error ->
Error
end.
-spec mailbox_below(pid(), non_neg_integer()) -> ok | {error, term()}.
mailbox_below(Pid, Max) ->
Args = [Pid, message_queue_len],
try erpc:call(node(Pid), erlang, process_info, Args, ?DEFAULT_ROUTE_TIMEOUT) of
{message_queue_len, Len} when Len =< Max -> ok;
Other -> {error, {source_mailbox, Other}}
catch
Class:Reason -> {error, {source_mailbox, Class, Reason}}
end.
-spec read_pins([node()]) -> {ok, saved_pins()} | {error, term()}.
read_pins(Nodes) ->
Results = erpc:multicall(
Nodes, application, get_env, [fluxer_gateway, ?PIN_KEY], ?DEFAULT_ROUTE_TIMEOUT
),
case [{N, R} || {N, R} <- lists:zip(Nodes, Results), not readable_pin(R)] of
[] -> {ok, maps:from_list([{N, V} || {N, {ok, V}} <- lists:zip(Nodes, Results)])};
Failed -> {error, {pins_unreadable, Failed}}
end.
-spec readable_pin(term()) -> boolean().
readable_pin({ok, undefined}) -> true;
readable_pin({ok, {ok, Pins}}) when is_map(Pins) -> true;
readable_pin(_) -> false.
-spec probe_pins([node()], node(), saved_pins()) -> ok | {error, term()}.
probe_pins(Nodes, Owner, Saved) ->
Probe = pin_and_verify(Nodes, ?PROBE_KEY, Owner, Saved),
Restore = write_pins([{N, maps:get(N, Saved, undefined)} || N <- Nodes]),
case {Probe, Restore} of
{ok, ok} -> ok;
_ -> {error, {pin_probe_failed, Probe, Restore}}
end.
-spec pin_and_verify([node()], guild_id(), node(), saved_pins()) -> ok | {error, term()}.
pin_and_verify([], _GuildId, _Owner, _Saved) ->
ok;
pin_and_verify(Nodes, GuildId, Owner, Saved) ->
Values = [{N, {ok, pinned(maps:get(N, Saved, undefined), GuildId, Owner)}} || N <- Nodes],
maybe
ok ?= write_pins(Values),
expect_routes(GuildId, Nodes, Owner)
end.
-spec restore_and_verify([node()], guild_id(), node(), saved_pins()) -> ok | {error, term()}.
restore_and_verify(Nodes, GuildId, Owner, Saved) ->
Values = [{N, maps:get(N, Saved, undefined)} || N <- Nodes],
maybe
ok ?= write_pins(Values),
expect_routes(GuildId, Nodes, Owner)
end.
-spec pinned({ok, term()} | undefined, guild_id(), node()) -> map().
pinned({ok, Pins}, GuildId, Owner) when is_map(Pins) ->
Pins#{GuildId => Owner};
pinned(_Previous, GuildId, Owner) ->
#{GuildId => Owner}.
-spec write_pins([{node(), {ok, term()} | undefined}]) -> ok | {error, term()}.
write_pins(Values) ->
Groups = maps:groups_from_list(fun({_N, V}) -> V end, fun({N, _V}) -> N end, Values),
Failed = maps:fold(fun write_group/3, [], Groups),
case Failed of
[] -> ok;
_ -> {error, {pin_write_failed, Failed}}
end.
-spec write_group({ok, term()} | undefined, [node()], list()) -> list().
write_group({ok, Value}, Nodes, Acc) ->
Args = [fluxer_gateway, ?PIN_KEY, Value],
collect_failures(
Nodes, erpc:multicall(Nodes, application, set_env, Args, ?DEFAULT_ROUTE_TIMEOUT), Acc
);
write_group(undefined, Nodes, Acc) ->
Args = [fluxer_gateway, ?PIN_KEY],
collect_failures(
Nodes, erpc:multicall(Nodes, application, unset_env, Args, ?DEFAULT_ROUTE_TIMEOUT), Acc
).
-spec collect_failures([node()], list(), list()) -> list().
collect_failures(Nodes, Results, Acc) ->
[{N, R} || {N, R} <- lists:zip(Nodes, Results), R =/= {ok, ok}] ++ Acc.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
cutover_refuses_once_the_pin_keeper_has_armed_test() ->
Opts = #{guild_id => 42, target_node => '[email protected]'},
application:set_env(fluxer_gateway, guild_pin_keeper_ever_armed, true),
try
?assertMatch(
{error, #{
phase := preflight,
reason := {pin_keeper_active, [{_, guild_pin_keeper_ever_armed}]}
}},
reverse(Opts)
)
after
application:unset_env(fluxer_gateway, guild_pin_keeper_ever_armed)
end,
?assertMatch(
{error, #{phase := preflight, reason := {pin_keeper_unreadable, _}}}, reverse(Opts)
).
exposed_abort_repairs_the_source_test() ->
Gone = spawn(fun() -> ok end),
Ref = erlang:monitor(process, Gone),
receive
{'DOWN', Ref, process, Gone, _} -> ok
end,
Exposed = #{abort => #{routes => ok, exposed => true}},
?assertEqual({error, noproc}, repair_source(42, Gone, Exposed, #{})),
Crashed = #{abort => #{routes => ok, target => ok}},
?assertEqual({error, noproc}, repair_source(42, Gone, Crashed, #{})),
Unexposed = #{abort => #{routes => ok, exposed => false}},
?assertEqual(skipped, repair_source(42, Gone, Unexposed, #{})),
Unrouted = #{abort => #{routes => skipped, exposed => true}},
?assertEqual(skipped, repair_source(42, Gone, Unrouted, #{})).
-endif.
+50 -19
View File
@@ -157,8 +157,10 @@ get_guild_state(UserId, State) ->
JoinedAt
).
-spec build_connect_snapshot(map(), guild_state()) -> map().
build_connect_snapshot(Item, State) ->
-spec build_connect_snapshot(map() | [map()], guild_state()) -> map().
build_connect_snapshot(Item, State) when is_map(Item) ->
build_connect_snapshot([Item], State);
build_connect_snapshot(Items, State) ->
Base = maps:with(
[
id,
@@ -172,28 +174,38 @@ build_connect_snapshot(Item, State) ->
],
State
),
project_snapshot_sessions(maybe_trim_connect_snapshot(Item, Base, State)).
project_snapshot_sessions(maybe_trim_connect_snapshot(Items, Base, State)).
-spec project_snapshot_sessions(map()) -> map().
project_snapshot_sessions(#{sessions := Sessions} = Snapshot) when is_map(Sessions) ->
Projected = maps:map(
fun(_SessionId, SessionData) -> project_snapshot_session(SessionData) end,
Sessions
),
Snapshot#{sessions => Projected};
case snapshot_reads_sessions(Snapshot) of
true ->
Projected = maps:map(
fun(_SessionId, SessionData) -> project_snapshot_session(SessionData) end,
Sessions
),
Snapshot#{sessions => Projected};
false ->
Snapshot#{sessions => #{}}
end;
project_snapshot_sessions(Snapshot) ->
Snapshot.
-spec snapshot_reads_sessions(map()) -> boolean().
snapshot_reads_sessions(Snapshot) ->
guild_availability_check:get_unavailability_mode_from_state(Snapshot) =:=
unavailable_for_everyone_but_staff.
-spec project_snapshot_session(term()) -> term().
project_snapshot_session(SessionData) when is_map(SessionData) ->
maps:without(?CONNECT_SNAPSHOT_HEAVY_SESSION_KEYS, SessionData);
project_snapshot_session(SessionData) ->
SessionData.
-spec maybe_trim_connect_snapshot(map(), map(), guild_state()) -> map().
maybe_trim_connect_snapshot(Item, Base, State) ->
-spec maybe_trim_connect_snapshot([map()], map(), guild_state()) -> map().
maybe_trim_connect_snapshot(Items, Base, State) ->
case should_trim_connect_snapshot(State) of
true -> trim_connect_snapshot(Item, Base);
true -> trim_connect_snapshot(Items, Base);
false -> Base
end.
@@ -205,9 +217,9 @@ should_trim_connect_snapshot(State) ->
has_members_ets(#{data := #{members_ets := Tab}}) -> is_reference(Tab);
has_members_ets(_) -> false.
-spec trim_connect_snapshot(map(), map()) -> map().
trim_connect_snapshot(Item, #{data := Data} = Base) when is_map(Data) ->
Retained = retained_member_map(Item, Base, Data),
-spec trim_connect_snapshot([map()], map()) -> map().
trim_connect_snapshot(Items, #{data := Data} = Base) when is_map(Data) ->
Retained = retained_member_map(Items, Base, Data),
Trimmed = maps:without(?CONNECT_SNAPSHOT_HEAVY_MEMBER_KEYS, Data),
Base#{
data => Trimmed#{
@@ -217,12 +229,12 @@ trim_connect_snapshot(Item, #{data := Data} = Base) when is_map(Data) ->
guild_data_index_members:build_member_role_index(Retained)
}
};
trim_connect_snapshot(_Item, Base) ->
trim_connect_snapshot(_Items, Base) ->
Base.
-spec retained_member_map(map(), map(), map()) -> #{integer() => map()}.
retained_member_map(Item, Base, Data) ->
UserIds = [connect_user_id(Item) | voice_state_user_ids(Base)],
-spec retained_member_map([map()], map(), map()) -> #{integer() => map()}.
retained_member_map(Items, Base, Data) ->
UserIds = [connect_user_id(Item) || Item <- Items] ++ voice_state_user_ids(Base),
lists:foldl(
fun(UserId, Acc) -> retain_member(UserId, Data, Acc) end,
#{},
@@ -480,8 +492,12 @@ guild_id_wire_value(GuildId) ->
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
staff_only_data() ->
#{<<"guild">> => #{<<"features">> => [<<"UNAVAILABLE_FOR_EVERYONE_BUT_STAFF">>]}}.
projection_snapshot() ->
#{
data => staff_only_data(),
sessions => #{
<<"s1">> => #{
session_id => <<"s1">>,
@@ -520,9 +536,24 @@ projection_leaves_snapshot_data_untouched_test() ->
?assertEqual(Data, maps:get(data, project_snapshot_sessions(Snapshot))).
projection_keeps_non_map_session_entries_test() ->
Snapshot = #{sessions => #{<<"broken">> => not_a_map}},
Snapshot = #{data => staff_only_data(), sessions => #{<<"broken">> => not_a_map}},
?assertEqual(Snapshot, project_snapshot_sessions(Snapshot)).
projection_drops_sessions_unless_staff_only_test() ->
Snapshot = projection_snapshot(),
lists:foreach(
fun(Data) ->
Projected = project_snapshot_sessions(Snapshot#{data => Data}),
?assertEqual(#{}, maps:get(sessions, Projected)),
?assertEqual(Data, maps:get(data, Projected))
end,
[
#{},
#{<<"guild">> => #{<<"features">> => []}},
#{<<"guild">> => #{<<"features">> => [<<"UNAVAILABLE_FOR_EVERYONE">>]}}
]
).
projection_keeps_snapshot_without_sessions_test() ->
Snapshot = #{id => 42, member_count => 3},
?assertEqual(Snapshot, project_snapshot_sessions(Snapshot)).
+40 -14
View File
@@ -21,6 +21,8 @@
-define(MAX_PARTNERS, 1000).
-define(PRUNE_SLACK, 64).
-define(VIEW_MEMO_KEY, dm_partner_view_memo).
-define(VIEW_MEMO_LIMIT, 4096).
-spec handle_cast(term(), guild_state()) -> {noreply, guild_state()}.
handle_cast({update_dm_partners, SessionId, PartnerIds}, State) when
@@ -84,21 +86,21 @@ channel_inputs(Channels) ->
-spec update(session_id(), [term()], guild_state()) -> guild_state().
update(SessionId, PartnerIds, State) ->
Registrations = registrations(State),
Next =
{Next, State1} =
case session_owner(SessionId, State) of
{ok, UserId, Pid} ->
update_owned(SessionId, UserId, Pid, PartnerIds, Registrations, State);
error ->
maps:remove(SessionId, Registrations)
{maps:remove(SessionId, Registrations), State}
end,
State#{dm_partners => maybe_prune(Next, State)}.
State1#{dm_partners => maybe_prune(Next, State1)}.
-spec update_owned(session_id(), user_id(), pid(), [term()], registrations(), guild_state()) ->
registrations().
{registrations(), guild_state()}.
update_owned(SessionId, UserId, Pid, PartnerIds, Registrations, State) ->
case presence_targets:dm_partner_presence_enabled(UserId) of
false ->
maps:remove(SessionId, Registrations);
{maps:remove(SessionId, Registrations), State};
true ->
Entry = #{
user_id => UserId,
@@ -108,14 +110,16 @@ update_owned(SessionId, UserId, Pid, PartnerIds, Registrations, State) ->
maps:get(SessionId, Registrations, undefined), Pid
)
},
put_all(evaluate(#{SessionId => Entry}, State), Registrations)
{Evaluated, State1} = evaluate(#{SessionId => Entry}, State),
{put_all(Evaluated, Registrations), State1}
end.
-spec reevaluate(scope(), registrations(), guild_state()) -> guild_state().
reevaluate(none, _Registrations, State) ->
State;
reevaluate(all, Registrations, State) ->
State#{dm_partners => evaluate(live_registrations(Registrations, State), State)};
{Evaluated, State1} = evaluate(live_registrations(Registrations, State), State),
State1#{dm_partners => Evaluated};
reevaluate({user, UserId}, Registrations, State) ->
Affected = maps:fold(
fun(SessionId, Entry, Acc) ->
@@ -135,7 +139,8 @@ reevaluate({user, UserId}, Registrations, State) ->
Kept = maps:without(
maps:keys(maps:without(maps:keys(Live), Affected)), Registrations
),
State#{dm_partners => maybe_prune(put_all(evaluate(Live, State), Kept), State)}
{Evaluated, State1} = evaluate(Live, State),
State1#{dm_partners => maybe_prune(put_all(Evaluated, Kept), State1)}
end.
-spec put_all(registrations(), registrations()) -> registrations().
@@ -156,22 +161,43 @@ maybe_prune(Registrations, State) ->
Registrations
end.
-spec evaluate(registrations(), guild_state()) -> registrations().
evaluate(Entries, _State) when map_size(Entries) =:= 0 ->
Entries;
-spec evaluate(registrations(), guild_state()) -> {registrations(), guild_state()}.
evaluate(Entries, State) when map_size(Entries) =:= 0 ->
{Entries, State};
evaluate(Entries, State) ->
Requests = [
{SessionId, UserId, maps:keys(Partners)}
|| {SessionId, #{user_id := UserId, partners := Partners}} <- maps:to_list(Entries)
],
Results = guild_subscription_mutual_channels:filter_session_member_ids(Requests, State),
{Memo0, Inputs} = view_memo(State),
{Results, Memo} = guild_subscription_mutual_channels:filter_session_member_ids(
Requests, Memo0, State
),
GuildId = maps:get(id, State),
maps:map(
Evaluated = maps:map(
fun(SessionId, Entry) ->
apply_result(GuildId, maps:get(SessionId, Results, []), Entry)
end,
Entries
).
),
{Evaluated, store_view_memo(Inputs, Memo, State)}.
-spec view_memo(guild_state()) ->
{guild_subscription_mutual_channels:view_memo(), term()}.
view_memo(State) ->
Inputs = visibility_inputs(State),
case maps:get(?VIEW_MEMO_KEY, State, undefined) of
#{inputs := Inputs, memo := Memo} when is_map(Memo) -> {Memo, Inputs};
_ -> {#{}, Inputs}
end.
-spec store_view_memo(term(), guild_subscription_mutual_channels:view_memo(), guild_state()) ->
guild_state().
store_view_memo(Inputs, Memo, State) ->
case map_size(maps:get(views, Memo, #{})) > ?VIEW_MEMO_LIMIT of
true -> maps:remove(?VIEW_MEMO_KEY, State);
false -> State#{?VIEW_MEMO_KEY => #{inputs => Inputs, memo => Memo}}
end.
-spec apply_result(integer(), [user_id()], registration()) -> registration().
apply_result(GuildId, EligibleIds, #{pid := Pid, eligible := Previous} = Entry) ->
+23 -5
View File
@@ -5,6 +5,7 @@
-export([
export_handoff_state/1,
derived_data_keys/0,
validate_handoff_state/1,
remonitor_transferred_sessions/1,
restore_transferred_session_state/1
@@ -18,7 +19,7 @@
export_handoff_state(State) ->
#{
id => maps:get(id, State),
data => maps:get(data, State, #{}),
data => maps:without(derived_data_keys(), maps:get(data, State, #{})),
sessions => export_handoff_sessions(maps:get(sessions, State, #{})),
voice_states => maps:get(voice_states, State, #{}),
virtual_channel_access => maps:get(virtual_channel_access, State, #{}),
@@ -30,6 +31,19 @@ export_handoff_state(State) ->
maps:get(virtual_channel_access_move_pending, State, #{})
}.
-spec derived_data_keys() -> [atom() | binary()].
derived_data_keys() ->
[
members_normalized,
members_sorted_ids,
member_list_revision,
members_ets,
role_perms_cache,
overwrite_perms_cache,
<<"role_index">>,
<<"member_role_index">>
].
-spec validate_handoff_state(term()) -> ok | {error, [atom()]}.
validate_handoff_state(Exported) when is_map(Exported) ->
RequiredKeys = [id, data, sessions, voice_states],
@@ -140,11 +154,15 @@ restore_transferred_session(_SessionId, _SessionData, State) ->
State.
-spec active_session_user_id(map()) -> integer() | undefined.
active_session_user_id(#{pending_connect := true}) ->
undefined;
active_session_user_id(SessionData) ->
case {maps:get(user_id, SessionData, undefined), maps:get(pid, SessionData, undefined)} of
{UserId, Pid} when is_integer(UserId), UserId > 0, is_pid(Pid) ->
case
{
guild_sessions_connect:counts_as_connected(SessionData),
maps:get(user_id, SessionData, undefined),
maps:get(pid, SessionData, undefined)
}
of
{true, UserId, Pid} when is_integer(UserId), UserId > 0, is_pid(Pid) ->
UserId;
_ ->
undefined
@@ -0,0 +1,941 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(guild_handoff_freeze).
-typing([eqwalizer]).
-export([
transfer/5,
repair/3,
default_opts/0,
is_frozen/1,
shard_pid/2,
ensure_absent/3
]).
-define(SHARD_TABLE, guild_manager_shard_table).
-define(GUARD_EXPRS,
"R = erlang:monitor(process, C),"
" C ! {freeze_guard_ready, self()},"
" receive"
" {freeze_guard_release, C} -> erlang:demonitor(R, [flush]), released;"
" {'DOWN', R, process, C, _} ->"
" try sys:resume(P, 60000) of ok -> resumed catch _:Reason -> {resume_failed, Reason} end"
" end."
).
-define(CAPTURE_EXPRS,
"F = fun({'$gen_call', _, _}) -> true;"
" ({'$gen_cast', _}) -> true;"
" ({presence, U, _}) when is_integer(U) -> true;"
" ({reconcile_user_presence, _}) -> true;"
" ({clear_stale_cached_voice_states, _}) -> true;"
" (_) -> false end,"
" case erlang:process_info(P, messages) of"
" {messages, Ms} ->"
" R = [M || M <- Ms, F(M)], L = length(R),"
" {L, case L >= S of true -> lists:nthtail(S, R); false -> [] end};"
" undefined -> undefined"
" end."
).
-define(DRAIN_POLL_MS, 10).
-define(REPAIR_EXPRS,
"D = erlang:monotonic_time(millisecond) + T,"
" case M:fetch_guild_data(G) of"
" {ok, Data} ->"
" case erlang:monotonic_time(millisecond) < D of"
" true ->"
" R = fun(S) -> case A:handle_call({reload, Data}, {self(), make_ref()}, S) of"
" {reply, _, S1} -> S1;"
" {reply, _, S1, _} -> S1"
" end end,"
" _ = sys:replace_state(P, R, max(1, D - erlang:monotonic_time(millisecond))),"
" reloaded;"
" false -> late"
" end;"
" Other -> {fetch_failed, Other}"
" end."
).
-define(EXPORT_EXPRS,
"maps:merge(maps:update_with(data, fun(D) -> maps:without(K, D) end,"
" guild_handoff:export_handoff_state(sys:get_state(P, T))), X)."
).
-type guild_id() :: integer().
-type hook() :: fun(() -> ok | {error, term()}).
-type started_hook() :: fun((pid()) -> ok | {error, term()}).
-type opts() :: #{
suspend_timeout => pos_integer(),
export_timeout => pos_integer(),
start_timeout => pos_integer(),
stop_timeout => pos_integer(),
barrier_timeout => pos_integer(),
rpc_timeout => pos_integer(),
guard_timeout => pos_integer(),
freeze_budget => pos_integer(),
commit_timeout => pos_integer(),
repair_timeout => pos_integer(),
forward_rounds => non_neg_integer(),
transfer_sessions => boolean(),
max_heap_words => non_neg_integer(),
measure => boolean(),
before_start => hook(),
after_start => started_hook(),
on_abort => hook()
}.
-type report() :: map().
-type result() :: {ok, report()} | {error, report()}.
-type freeze() :: #{pid := pid(), guard := pid(), guard_ref := reference()}.
-export_type([opts/0, report/0, result/0]).
-spec default_opts() -> opts().
default_opts() ->
#{
suspend_timeout => 30000,
export_timeout => 120000,
start_timeout => 120000,
stop_timeout => 60000,
barrier_timeout => 30000,
rpc_timeout => 10000,
guard_timeout => 10000,
freeze_budget => 90000,
commit_timeout => 300000,
repair_timeout => 30000,
forward_rounds => 3,
transfer_sessions => true,
max_heap_words => 0,
measure => false,
before_start => fun() -> ok end,
after_start => fun(_Pid) -> ok end,
on_abort => fun() -> ok end
}.
-spec transfer(guild_id(), pid(), pid(), node(), opts()) -> result().
transfer(GuildId, SrcPid, SrcShard, TargetNode, Opts0) ->
Opts = maps:merge(default_opts(), Opts0),
Parent = self(),
Tag = make_ref(),
Notify = fun(Event) ->
Parent ! {Tag, Event},
ok
end,
{Pid, Ref} = spawn_monitor(fun() ->
process_flag(trap_exit, true),
ok = maybe_limit_heap(maps:get(max_heap_words, Opts)),
Parent ! {Tag, run(GuildId, SrcPid, SrcShard, TargetNode, Opts, Notify)}
end),
await(GuildId, TargetNode, {Pid, Ref, Tag}, false, Opts).
-spec await(guild_id(), node(), {pid(), reference(), reference()}, boolean(), opts()) ->
result().
await(GuildId, TargetNode, {Pid, Ref, Tag} = Run, Committed, Opts) ->
receive
{Tag, committed} ->
await(GuildId, TargetNode, Run, true, Opts);
{Tag, Result} ->
erlang:demonitor(Ref, [flush]),
Result;
{'DOWN', Ref, process, Pid, Reason} ->
crashed(GuildId, TargetNode, Reason, Committed, Opts)
end.
-spec crashed(guild_id(), node(), term(), boolean(), opts()) -> result().
crashed(_GuildId, _TargetNode, Reason, true, _Opts) ->
{error, #{phase => crashed, reason => Reason, committed => true}};
crashed(GuildId, TargetNode, Reason, false, Opts) ->
Routes = safe_hook(maps:get(on_abort, Opts)),
Target = ensure_absent(GuildId, TargetNode, Opts),
{error, #{
phase => crashed, reason => Reason, abort => #{routes => Routes, target => Target}
}}.
-spec repair(guild_id(), pid(), opts()) -> ok | {error, term()}.
repair(GuildId, SrcPid, Opts0) ->
Opts = maps:merge(default_opts(), Opts0),
maybe
ok ?= await_thawed(SrcPid, now_ms() + maps:get(guard_timeout, Opts)),
{ok, Freeze} ?= freeze(SrcPid, Opts),
Reload = reload_frozen(GuildId, SrcPid, maps:get(repair_timeout, Opts)),
repaired(Reload, thaw(Freeze, Opts))
end.
-spec await_thawed(pid(), integer()) -> ok | {error, term()}.
await_thawed(SrcPid, Deadline) ->
case is_frozen(SrcPid) of
false ->
ok;
true ->
case now_ms() < Deadline of
true ->
timer:sleep(?DRAIN_POLL_MS),
await_thawed(SrcPid, Deadline);
false ->
{error, still_frozen}
end;
{error, _} = Error ->
Error
end.
-spec reload_frozen(guild_id(), pid(), pos_integer()) -> ok | {error, term()}.
reload_frozen(GuildId, SrcPid, Timeout) ->
Bindings = bindings([
{'G', GuildId},
{'P', SrcPid},
{'T', Timeout},
{'M', guild_manager_shard_fetch},
{'A', guild}
]),
Args = [parse(?REPAIR_EXPRS), Bindings],
case rpc_call(node(SrcPid), erl_eval, exprs, Args, Timeout + 5000) of
{ok, {value, reloaded, _}} -> ok;
{ok, {value, Other, _}} -> {error, Other};
{error, _} = Error -> Error
end.
-spec repaired(ok | {error, term()}, ok | {error, term()}) -> ok | {error, term()}.
repaired(ok, ok) ->
ok;
repaired(Reload, Thaw) ->
{error, #{reload => Reload, thaw => Thaw}}.
-spec is_frozen(pid()) -> boolean() | {error, term()}.
is_frozen(Pid) ->
case rpc_call(node(Pid), erlang, process_info, [Pid, current_function], 10000) of
{ok, {current_function, {sys, _, _}}} -> true;
{ok, {current_function, _}} -> false;
{ok, undefined} -> {error, noproc};
{ok, Other} -> {error, {unexpected, Other}};
{error, _} = Error -> Error
end.
-spec shard_pid(guild_id(), node()) -> {ok, pid()} | {error, term()}.
shard_pid(GuildId, Node) ->
case rpc_call(Node, ets, lookup, [?SHARD_TABLE, shard_count], 10000) of
{ok, [{shard_count, Count}]} when is_integer(Count), Count > 0 ->
Index = guild_manager_shards:select_shard(GuildId, Count),
indexed_shard_pid(Node, Index);
{ok, _} ->
{error, shard_table_unavailable};
{error, _} = Error ->
Error
end.
-spec ensure_absent(guild_id(), node(), opts()) -> ok | {error, term()}.
ensure_absent(GuildId, Node, Opts0) ->
Opts = maps:merge(default_opts(), Opts0),
StopTimeout = maps:get(start_timeout, Opts) + maps:get(stop_timeout, Opts),
case shard_pid(GuildId, Node) of
{ok, Shard} ->
_ = safe_call(Shard, {stop_guild, GuildId}, StopTimeout),
case safe_call(Shard, {lookup, GuildId}, maps:get(rpc_timeout, Opts)) of
{error, not_found} -> ok;
{ok, Pid} when is_pid(Pid) -> {error, {still_running, Pid}};
Other -> {error, {unverified, Other}}
end;
{error, _} = Error ->
Error
end.
-spec run(guild_id(), pid(), pid(), node(), opts(), fun((committed) -> ok)) -> result().
run(GuildId, SrcPid, SrcShard, TargetNode, Opts, Notify) ->
Ctx = #{
notify => Notify,
guild_id => GuildId,
src => SrcPid,
src_shard => SrcShard,
target => TargetNode,
opts => Opts,
t0 => now_ms(),
report => #{source => SrcPid, target_node => TargetNode}
},
case is_frozen(SrcPid) of
false -> run_freeze(Ctx);
true -> fail(preflight, already_frozen, Ctx);
{error, Reason} -> fail(preflight, Reason, Ctx)
end.
-spec run_freeze(map()) -> result().
run_freeze(#{src := SrcPid, opts := Opts} = Ctx) ->
T0 = now_ms(),
case freeze(SrcPid, Opts) of
{ok, Freeze} ->
Ctx1 = put_ms(suspend_ms, T0, Ctx#{freeze => Freeze, frozen_at => now_ms()}),
run_export(Ctx1);
{error, Reason} ->
fail(freeze, Reason, Ctx)
end.
-spec run_export(map()) -> result().
run_export(#{src := SrcPid, opts := Opts} = Ctx) ->
T0 = now_ms(),
case export(SrcPid, Opts) of
{ok, Export} ->
Ctx1 = put_ms(export_ms, T0, Ctx),
Ctx2 = put_report(export_measure, measure_export(Export, Opts), Ctx1),
run_before_start(Export, Ctx2);
{error, Reason} ->
abort(export, Reason, Ctx)
end.
-spec run_before_start(map(), map()) -> result().
run_before_start(Export, #{opts := Opts} = Ctx) ->
case {within_budget(Ctx), guard_held(Ctx)} of
{true, ok} -> run_hook(before_start, maps:get(before_start, Opts), Export, Ctx);
{false, _} -> abort(before_start, freeze_budget_exceeded, Ctx);
{true, {error, Reason}} -> abort(before_start, Reason, Ctx)
end.
-spec run_hook(before_start, hook(), map(), map()) -> result().
run_hook(Phase, Hook, Export, Ctx) ->
case safe_hook(Hook) of
ok -> run_start(Export, Ctx#{routed => true});
{error, Reason} -> abort(Phase, Reason, Ctx#{routed => true})
end.
-spec run_start(map(), map()) -> result().
run_start(Export, #{guild_id := GuildId, target := Target, opts := Opts} = Ctx) ->
T0 = now_ms(),
Ctx1 = Ctx#{start_sent => true},
case start_on(GuildId, Target, Export, maps:get(start_timeout, Opts)) of
{ok, NewPid} ->
run_after_start(NewPid, put_ms(start_ms, T0, Ctx1#{new_pid => NewPid}));
{error, Reason} ->
abort(start, Reason, put_ms(start_ms, T0, Ctx1))
end.
-spec run_after_start(pid(), map()) -> result().
run_after_start(NewPid, #{opts := Opts} = Ctx) ->
case guard_held(Ctx) of
ok -> run_route(NewPid, maps:get(after_start, Opts), Ctx#{exposed => true});
{error, Reason} -> abort(after_start, Reason, Ctx)
end.
-spec run_route(pid(), started_hook(), map()) -> result().
run_route(NewPid, Hook, Ctx) ->
T0 = now_ms(),
case safe_started_hook(Hook, NewPid) of
ok -> run_commit(NewPid, put_ms(route_ms, T0, Ctx));
{error, Reason} -> abort(after_start, Reason, put_ms(route_ms, T0, Ctx))
end.
-spec run_commit(pid(), map()) -> result().
run_commit(NewPid, #{src := SrcPid, opts := Opts} = Ctx) ->
case forward_rounds(SrcPid, NewPid, maps:get(forward_rounds, Opts), 0, [], Opts) of
{ok, Seen, Rounds} ->
commit_final(NewPid, Seen, rounds_report(Rounds, Ctx));
{error, {capture_failed, noproc}, _Seen, Rounds} ->
source_gone(NewPid, rounds_report(Rounds, Ctx));
{error, {capture_failed, _} = Reason, Seen, Rounds} ->
Ctx1 = put_report(rounds_stopped, Reason, rounds_report(Rounds, Ctx)),
commit_final(NewPid, Seen, Ctx1);
{error, Reason, _Seen, Rounds} ->
abort(forward, Reason, rounds_report(Rounds, Ctx))
end.
-spec rounds_report([map()], map()) -> map().
rounds_report(Rounds, Ctx) ->
Forwarded = lists:foldl(fun(Round, Acc) -> Acc + forwarded_count(Round) end, 0, Rounds),
add_forwarded(Forwarded, put_report(rounds, lists:reverse(Rounds), Ctx)).
-spec add_forwarded(non_neg_integer(), map()) -> map().
add_forwarded(Count, Ctx) ->
Ctx#{forwarded => maps:get(forwarded, Ctx, 0) + Count}.
-spec forwarded_count(map()) -> non_neg_integer().
forwarded_count(Stats) ->
lists:foldl(
fun(Kind, Acc) ->
case maps:get(Kind, Stats, 0) of
Count when is_integer(Count), Count > 0 -> Acc + Count;
_ -> Acc
end
end,
0,
[call, cast, presence_repair, voice_cleanup]
).
-spec commit_final(pid(), non_neg_integer(), map()) -> result().
commit_final(NewPid, Seen, #{src := SrcPid} = Ctx) ->
case {target_exit(NewPid), guard_held(Ctx), is_frozen(SrcPid)} of
{{exited, Reason}, _, _} ->
abort(target_died, Reason, Ctx);
{alive, {error, Reason}, _} ->
abort(final_capture, Reason, Ctx);
{alive, ok, true} ->
stop_and_drain(NewPid, Seen, Ctx);
{alive, ok, {error, noproc}} ->
source_gone(NewPid, Ctx);
{alive, ok, Frozen} ->
abort(final_capture, {not_frozen, Frozen}, Ctx)
end.
-spec guard_held(map()) -> ok | {error, term()}.
guard_held(#{freeze := #{guard := Guard, guard_ref := Ref}}) ->
receive
{'DOWN', Ref, process, Guard, Reason} -> {error, {guard_lost, Reason}}
after 0 -> ok
end.
-spec target_exit(pid()) -> alive | {exited, term()}.
target_exit(NewPid) ->
receive
{'EXIT', NewPid, Reason} -> {exited, Reason}
after 0 -> alive
end.
-spec source_gone(pid(), map()) -> result().
source_gone(NewPid, #{src := SrcPid, opts := Opts} = Ctx) ->
case {target_exit(NewPid), source_alive(SrcPid, Opts)} of
{alive, false} -> finish(NewPid, put_report(source_gone, true, Ctx));
Other -> abort(source_gone, {source_state, Other}, Ctx)
end.
-spec stop_and_drain(pid(), non_neg_integer(), map()) -> result().
stop_and_drain(NewPid, Seen, #{src := SrcPid, opts := Opts} = Ctx) ->
case capture(SrcPid, Seen, Opts) of
{ok, _Len, New} ->
Stats = forward(New, NewPid, final),
ok = commit(NewPid, Ctx),
Parent = self(),
Tag = make_ref(),
{Pid, Ref} = spawn_monitor(fun() -> Parent ! {Tag, stop_source(Ctx)} end),
Drain = #{
stopper => {Pid, Ref, Tag},
stop => pending,
stats => Stats,
captures => 1,
t0 => now_ms()
},
drain(NewPid, Seen + length(New), Drain, Ctx);
{error, noproc} ->
source_gone(NewPid, Ctx);
{error, Reason} ->
abort(final_capture, Reason, Ctx)
end.
-spec commit(pid(), map()) -> ok.
commit(NewPid, #{freeze := Freeze, notify := Notify}) ->
release_guard(Freeze),
unlink(NewPid),
receive
{'EXIT', NewPid, _Reason} -> ok
after 0 -> ok
end,
Notify(committed).
-spec drain(pid(), non_neg_integer(), map(), map()) -> result().
drain(NewPid, Seen, Drain, #{src := SrcPid, opts := Opts} = Ctx) ->
case capture(SrcPid, Seen, Opts) of
{ok, _Len, New} ->
Stats = merge_counts(maps:get(stats, Drain), forward(New, NewPid, final)),
Drain1 = Drain#{stats => Stats, captures => maps:get(captures, Drain) + 1},
drain_alive(NewPid, Seen + length(New), poll_stopper(Drain1, ?DRAIN_POLL_MS), Ctx);
{error, noproc} ->
drained(NewPid, poll_stopper(Drain, maps:get(stop_timeout, Opts)), Ctx);
{error, Reason} ->
Polled = poll_stopper(Drain, maps:get(stop_timeout, Opts)),
drain_unknown(NewPid, Seen, Reason, Polled, Ctx)
end.
-spec drain_alive(pid(), non_neg_integer(), map(), map()) -> result().
drain_alive(NewPid, Seen, #{stop := pending} = Drain, Ctx) ->
drain(NewPid, Seen, Drain, Ctx);
drain_alive(NewPid, Seen, #{stop := ok} = Drain, #{src := SrcPid, opts := Opts} = Ctx) ->
case now_ms() - maps:get(t0, Drain) > maps:get(stop_timeout, Opts) of
true -> exit(SrcPid, kill);
false -> timer:sleep(?DRAIN_POLL_MS)
end,
drain(NewPid, Seen, Drain, Ctx);
drain_alive(NewPid, Seen, #{stop := Reason} = Drain, Ctx) ->
await_source(NewPid, Seen, Reason, Drain, Ctx).
-spec drain_unknown(pid(), non_neg_integer(), term(), map(), map()) -> result().
drain_unknown(NewPid, Seen, Reason, Drain, #{src := SrcPid, opts := Opts} = Ctx) ->
case source_alive(SrcPid, Opts) of
false ->
drained(NewPid, Drain, Ctx);
_ ->
await_source(
NewPid, Seen, {capture_failed, Reason, maps:get(stop, Drain)}, Drain, Ctx
)
end.
-spec await_source(pid(), non_neg_integer(), term(), map(), map()) -> result().
await_source(NewPid, Seen, Reason, Drain, #{opts := Opts} = Ctx) ->
case now_ms() - maps:get(t0, Drain) > maps:get(commit_timeout, Opts) of
true ->
stuck(NewPid, Reason, Drain, Ctx);
false ->
timer:sleep(?DRAIN_POLL_MS),
drain(NewPid, Seen, Drain, Ctx)
end.
-spec stuck(pid(), term(), map(), map()) -> result().
stuck(NewPid, Reason, Drain, Ctx) ->
Forwarded = maps:get(forwarded, Ctx, 0) + forwarded_count(maps:get(stats, Drain)),
Report = maps:get(report, Ctx),
{error, Report#{
phase => stop,
reason => Reason,
committed => true,
new_pid => NewPid,
forwarded => Forwarded,
total_ms => now_ms() - maps:get(t0, Ctx)
}}.
-spec drained(pid(), map(), map()) -> result().
drained(NewPid, Drain, Ctx) ->
Final = (maps:get(stats, Drain))#{
captures => maps:get(captures, Drain),
stop => maps:get(stop, Drain)
},
Ctx1 = put_report(stop_ms, now_ms() - maps:get(t0, Drain), Ctx),
finish(NewPid, put_report(final, Final, Ctx1)).
-spec poll_stopper(map(), non_neg_integer()) -> map().
poll_stopper(#{stop := pending, stopper := {Pid, Ref, Tag}} = Drain, Timeout) ->
receive
{Tag, Result} ->
erlang:demonitor(Ref, [flush]),
Drain#{stop => Result};
{'DOWN', Ref, process, Pid, Reason} ->
Drain#{stop => {error, {stopper_crashed, Reason}}}
after Timeout ->
Drain
end;
poll_stopper(Drain, _Timeout) ->
Drain.
-spec merge_counts(map(), map()) -> map().
merge_counts(Left, Right) ->
maps:merge_with(fun(_Key, A, B) -> A + B end, Left, Right).
-spec finish(pid(), map()) -> result().
finish(NewPid, #{freeze := Freeze, frozen_at := FrozenAt} = Ctx) ->
release_guard(Freeze),
unlink(NewPid),
Report = maps:get(report, Ctx),
{ok, Report#{
new_pid => NewPid,
frozen_ms => now_ms() - FrozenAt,
total_ms => now_ms() - maps:get(t0, Ctx)
}}.
-spec fail(atom(), term(), map()) -> result().
fail(Phase, Reason, Ctx) ->
Report = maps:get(report, Ctx),
{error, Report#{phase => Phase, reason => Reason, total_ms => now_ms() - maps:get(t0, Ctx)}}.
-spec abort(atom(), term(), map()) -> result().
abort(Phase, Reason, #{opts := Opts} = Ctx) ->
Routes = abort_routes(Ctx, maps:get(on_abort, Opts)),
Target = abort_target(Ctx),
Thaw = thaw(maps:get(freeze, Ctx), Opts),
Report = maps:get(report, Ctx),
{error, Report#{
phase => Phase,
reason => Reason,
abort => #{
routes => Routes,
target => Target,
thaw => Thaw,
exposed => maps:get(exposed, Ctx, false),
forwarded => maps:get(forwarded, Ctx, 0)
},
total_ms => now_ms() - maps:get(t0, Ctx)
}}.
-spec abort_routes(map(), hook()) -> ok | {error, term()} | skipped.
abort_routes(#{routed := true}, Hook) ->
safe_hook(Hook);
abort_routes(_Ctx, _Hook) ->
skipped.
-spec abort_target(map()) -> ok | {error, term()} | skipped.
abort_target(#{start_sent := true, guild_id := GuildId, target := Target, opts := Opts} = Ctx) ->
Result = ensure_absent(GuildId, Target, Opts),
case maps:get(new_pid, Ctx, undefined) of
Pid when is_pid(Pid) -> unlink_and_kill_if(Result, Pid);
_ -> Result
end;
abort_target(_Ctx) ->
skipped.
-spec unlink_and_kill_if(ok | {error, term()}, pid()) -> ok | {error, term()}.
unlink_and_kill_if(ok, Pid) ->
unlink(Pid),
ok;
unlink_and_kill_if({error, _} = Error, Pid) ->
unlink(Pid),
exit(Pid, kill),
Error.
-spec freeze(pid(), opts()) -> {ok, freeze()} | {error, term()}.
freeze(SrcPid, Opts) ->
case spawn_guard(SrcPid, maps:get(guard_timeout, Opts)) of
{ok, Guard, GuardRef} ->
Freeze = #{pid => SrcPid, guard => Guard, guard_ref => GuardRef},
case suspend(SrcPid, maps:get(suspend_timeout, Opts)) of
ok ->
{ok, Freeze};
{error, Reason} ->
_ = thaw(Freeze, Opts),
{error, {suspend_failed, Reason}}
end;
{error, Reason} ->
{error, {guard_failed, Reason}}
end.
-spec thaw(freeze(), opts()) -> ok | {error, term()}.
thaw(#{pid := SrcPid} = Freeze, Opts) ->
Result =
try sys:resume(SrcPid, maps:get(suspend_timeout, Opts)) of
ok -> ok
catch
exit:Reason -> {error, Reason}
end,
release_guard(Freeze),
Result.
-spec suspend(pid(), pos_integer()) -> ok | {error, term()}.
suspend(SrcPid, Timeout) ->
try sys:suspend(SrcPid, Timeout) of
ok -> ok
catch
exit:Reason -> {error, Reason}
end.
-spec spawn_guard(pid(), pos_integer()) -> {ok, pid(), reference()} | {error, term()}.
spawn_guard(SrcPid, Timeout) ->
Bindings = bindings([{'C', self()}, {'P', SrcPid}]),
Guard = erlang:spawn(node(SrcPid), erl_eval, exprs, [parse(?GUARD_EXPRS), Bindings]),
Ref = erlang:monitor(process, Guard),
receive
{freeze_guard_ready, Guard} -> {ok, Guard, Ref};
{'DOWN', Ref, process, Guard, Reason} -> {error, Reason}
after Timeout ->
erlang:demonitor(Ref, [flush]),
exit(Guard, kill),
{error, guard_timeout}
end.
-spec release_guard(freeze()) -> ok.
release_guard(#{guard := Guard, guard_ref := Ref}) ->
erlang:demonitor(Ref, [flush]),
Guard ! {freeze_guard_release, self()},
ok.
-spec export(pid(), opts()) -> {ok, map()} | {error, term()}.
export(SrcPid, Opts) ->
Timeout = maps:get(export_timeout, Opts),
Bindings = bindings([
{'P', SrcPid},
{'T', Timeout},
{'K', guild_handoff:derived_data_keys()},
{'X', export_overrides(Opts)}
]),
Args = [parse(?EXPORT_EXPRS), Bindings],
case rpc_call(node(SrcPid), erl_eval, exprs, Args, Timeout + 5000) of
{ok, {value, Export, _}} -> validate_export(Export);
{ok, Other} -> {error, {unexpected_export, Other}};
{error, _} = Error -> Error
end.
-spec export_overrides(opts()) -> map().
export_overrides(#{transfer_sessions := false}) ->
#{sessions => #{}};
export_overrides(_Opts) ->
#{}.
-spec validate_export(term()) -> {ok, map()} | {error, term()}.
validate_export(Export) when is_map(Export) ->
case guild_handoff:validate_handoff_state(Export) of
ok -> {ok, Export};
{error, Missing} -> {error, {invalid_export, Missing}}
end;
validate_export(Other) ->
{error, {invalid_export, Other}}.
-spec measure_export(map(), opts()) -> map() | skipped.
measure_export(Export, #{measure := true}) ->
T0 = now_ms(),
Bytes = erlang:external_size(Export),
#{external_bytes => Bytes, measure_ms => now_ms() - T0};
measure_export(_Export, _Opts) ->
skipped.
-spec start_on(guild_id(), node(), map(), pos_integer()) -> {ok, pid()} | {error, term()}.
start_on(GuildId, Target, Export, Timeout) ->
case shard_pid(GuildId, Target) of
{ok, Shard} ->
case safe_call(Shard, {start_transferred, GuildId, Export}, Timeout) of
{ok, NewPid} when is_pid(NewPid) ->
link(NewPid),
{ok, NewPid};
{error, Reason} ->
{error, Reason};
Other ->
{error, {unexpected_start_reply, Other}}
end;
{error, _} = Error ->
Error
end.
-spec forward_rounds(pid(), pid(), non_neg_integer(), non_neg_integer(), [map()], opts()) ->
{ok, non_neg_integer(), [map()]} | {error, term(), non_neg_integer(), [map()]}.
forward_rounds(_SrcPid, _NewPid, 0, Seen, Rounds, _Opts) ->
{ok, Seen, Rounds};
forward_rounds(SrcPid, NewPid, Left, Seen, Rounds, Opts) ->
case capture(SrcPid, Seen, Opts) of
{ok, Len, _New} when Len < Seen ->
{error, mailbox_shrank, Seen, Rounds};
{ok, _Len, []} ->
{ok, Seen, Rounds};
{ok, _Len, New} ->
Stats = forward(New, NewPid, rounds),
T0 = now_ms(),
Barrier = barrier(NewPid, maps:get(barrier_timeout, Opts)),
Round = Stats#{
captured => length(New), barrier => Barrier, barrier_ms => now_ms() - T0
},
forward_rounds(
SrcPid, NewPid, Left - 1, Seen + length(New), [Round | Rounds], Opts
);
{error, Reason} ->
{error, {capture_failed, Reason}, Seen, Rounds}
end.
-spec forward([term()], pid(), rounds | final) -> map().
forward(Msgs, NewPid, Phase) ->
lists:foldl(
fun(Msg, Acc) ->
Kind = forward_msg(Msg, NewPid, Phase),
maps:update_with(Kind, fun(N) -> N + 1 end, 1, Acc)
end,
#{},
Msgs
).
-spec forward_msg(term(), pid(), rounds | final) -> atom().
forward_msg({'$gen_call', _From, _Request} = Msg, NewPid, rounds) ->
NewPid ! Msg,
call;
forward_msg({'$gen_call', _From, _Request}, _NewPid, final) ->
dropped_call;
forward_msg({'$gen_cast', {session_connect_worker_done, _, _, _, _}}, _NewPid, _Phase) ->
dropped_worker_result;
forward_msg({'$gen_cast', {session_connect_worker_batch_done, _}}, _NewPid, _Phase) ->
dropped_worker_result;
forward_msg({'$gen_cast', _Request} = Msg, NewPid, _Phase) ->
NewPid ! Msg,
cast;
forward_msg({presence, UserId, _Payload}, NewPid, _Phase) ->
NewPid ! {reconcile_user_presence, UserId},
presence_repair;
forward_msg({reconcile_user_presence, _UserId} = Msg, NewPid, _Phase) ->
NewPid ! Msg,
presence_repair;
forward_msg({clear_stale_cached_voice_states, _Ids} = Msg, NewPid, _Phase) ->
NewPid ! Msg,
voice_cleanup.
-spec barrier(pid(), pos_integer()) -> ok | {error, term()}.
barrier(NewPid, Timeout) ->
case safe_call(NewPid, {get_guild_id}, Timeout) of
Id when is_integer(Id) -> ok;
Other -> {error, Other}
end.
-spec capture(pid(), non_neg_integer(), opts()) ->
{ok, non_neg_integer(), [term()]} | {error, term()}.
capture(SrcPid, Seen, Opts) ->
Args = [parse(?CAPTURE_EXPRS), bindings([{'P', SrcPid}, {'S', Seen}])],
case rpc_call(node(SrcPid), erl_eval, exprs, Args, maps:get(rpc_timeout, Opts)) of
{ok, {value, {Len, New}, _}} when is_integer(Len), is_list(New) -> {ok, Len, New};
{ok, {value, undefined, _}} -> {error, noproc};
{ok, Other} -> {error, {unexpected, Other}};
{error, _} = Error -> Error
end.
-spec stop_source(map()) -> ok | {error, term()}.
stop_source(#{guild_id := GuildId, src_shard := Shard, opts := Opts}) ->
Request = {stop_guild, GuildId, {shutdown, handoff}},
case safe_call(Shard, Request, maps:get(stop_timeout, Opts)) of
ok -> ok;
Other -> {error, Other}
end.
-spec source_alive(pid(), opts()) -> boolean() | {error, term()}.
source_alive(SrcPid, Opts) ->
Args = [SrcPid],
case rpc_call(node(SrcPid), erlang, is_process_alive, Args, maps:get(rpc_timeout, Opts)) of
{ok, Alive} when is_boolean(Alive) -> Alive;
{ok, Other} -> {error, {unexpected, Other}};
{error, _} = Error -> Error
end.
-spec within_budget(map()) -> boolean().
within_budget(#{frozen_at := FrozenAt, opts := Opts}) ->
now_ms() - FrozenAt =< maps:get(freeze_budget, Opts).
-spec indexed_shard_pid(node(), non_neg_integer()) -> {ok, pid()} | {error, term()}.
indexed_shard_pid(Node, Index) ->
case rpc_call(Node, ets, lookup, [?SHARD_TABLE, {shard_pid, Index}], 10000) of
{ok, [{{shard_pid, Index}, Pid}]} when is_pid(Pid) -> {ok, Pid};
{ok, _} -> {error, shard_unavailable};
{error, _} = Error -> Error
end.
-spec safe_hook(hook()) -> ok | {error, term()}.
safe_hook(Hook) ->
try Hook() of
ok -> ok;
{error, _} = Error -> Error
catch
Class:Reason -> {error, {hook_crashed, Class, Reason}}
end.
-spec safe_started_hook(started_hook(), pid()) -> ok | {error, term()}.
safe_started_hook(Hook, NewPid) ->
try Hook(NewPid) of
ok -> ok;
{error, _} = Error -> Error
catch
Class:Reason -> {error, {hook_crashed, Class, Reason}}
end.
-spec safe_call(pid(), term(), pos_integer()) -> term().
safe_call(Server, Request, Timeout) ->
try
gen_server:call(Server, Request, Timeout)
catch
exit:{timeout, _} -> {error, timeout};
exit:Reason -> {error, {exit, Reason}}
end.
-spec rpc_call(node(), module(), atom(), [term()], pos_integer()) ->
{ok, term()} | {error, term()}.
rpc_call(Node, Module, Function, Args, Timeout) ->
try erpc:call(Node, Module, Function, Args, Timeout) of
Result -> {ok, Result}
catch
Class:Reason -> {error, {Class, Reason}}
end.
-spec parse(string()) -> [erl_parse:abstract_expr()].
parse(Source) ->
{ok, Tokens, _} = erl_scan:string(Source),
{ok, Exprs} = erl_parse:parse_exprs(Tokens),
Exprs.
-spec bindings([{atom(), term()}]) -> erl_eval:binding_struct().
bindings(Pairs) ->
lists:foldl(
fun({Name, Value}, Acc) -> erl_eval:add_binding(Name, Value, Acc) end,
erl_eval:new_bindings(),
Pairs
).
-spec maybe_limit_heap(non_neg_integer()) -> ok.
maybe_limit_heap(0) ->
ok;
maybe_limit_heap(Words) ->
_ = process_flag(max_heap_size, #{size => Words, kill => true, error_logger => true}),
ok.
-spec put_ms(atom(), integer(), map()) -> map().
put_ms(Key, T0, Ctx) ->
put_report(Key, now_ms() - T0, Ctx).
-spec put_report(atom(), term(), map()) -> map().
put_report(Key, Value, #{report := Report} = Ctx) ->
Ctx#{report => Report#{Key => Value}}.
-spec now_ms() -> integer().
now_ms() ->
erlang:monotonic_time(millisecond).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
capture_returns_only_new_forwardable_messages_test() ->
Pid = spawn(fun() ->
receive
stop -> ok
end
end),
Msgs = [
{'$gen_cast', a},
{'DOWN', make_ref(), process, self(), normal},
{'$gen_call', {self(), make_ref()}, b},
{presence, not_a_user, x},
{presence, 7, x},
{reconcile_user_presence, 7},
{clear_stale_cached_voice_states, []}
],
[Pid ! Msg || Msg <- Msgs],
Opts = default_opts(),
try
?assertMatch({ok, 5, [_, _, _, _, _]}, capture(Pid, 0, Opts)),
?assertEqual(
{ok, 5, [
{presence, 7, x},
{reconcile_user_presence, 7},
{clear_stale_cached_voice_states, []}
]},
capture(Pid, 2, Opts)
),
?assertEqual({ok, 5, []}, capture(Pid, 5, Opts)),
?assertEqual({ok, 5, []}, capture(Pid, 9, Opts))
after
Pid ! stop
end,
ok = wait_exit(Pid),
?assertEqual({error, noproc}, capture(Pid, 0, Opts)).
repair_applies_the_reload_before_dispatches_queued_during_the_fetch_test() ->
Test = self(),
meck:new(guild_repair_stub, [non_strict]),
meck:expect(guild_repair_stub, init, fun(State) -> {ok, State} end),
meck:expect(guild_repair_stub, handle_cast, fun({set, Value}, _State) ->
{noreply, Value}
end),
meck:new(guild_manager_shard_fetch, [passthrough]),
meck:expect(guild_manager_shard_fetch, fetch_guild_data, fun(42) ->
Test ! {fetching, self()},
receive
go -> {ok, fetched}
end
end),
meck:new(guild, [passthrough]),
meck:expect(guild, handle_call, fun({reload, Data}, _From, _State) -> {reply, ok, Data} end),
{ok, Pid} = gen_server:start(guild_repair_stub, before, []),
try
spawn(fun() -> Test ! {repaired, repair(42, Pid, #{})} end),
receive
{fetching, Fetcher} ->
?assert(is_frozen(Pid)),
gen_server:cast(Pid, {set, dispatched}),
Fetcher ! go
after 5000 -> error(no_fetch)
end,
?assertEqual(
ok,
receive
{repaired, Result} -> Result
after 5000 -> timeout
end
),
?assertEqual(dispatched, sys:get_state(Pid))
after
exit(Pid, kill),
meck:unload([guild, guild_manager_shard_fetch, guild_repair_stub])
end.
wait_exit(Pid) ->
Ref = erlang:monitor(process, Pid),
receive
{'DOWN', Ref, process, Pid, _} -> ok
after 5000 -> timeout
end.
-endif.
+111 -34
View File
@@ -6,7 +6,7 @@
-export([
start_link/0,
is_degraded/1,
is_overloaded/1,
register_guild/1,
put_session/2,
remove_session/2,
@@ -19,19 +19,23 @@
-define(DEGRADED_MS, 2000).
-define(RECOVERED_MS, 500).
-define(REMOTE_LOOKUP_TIMEOUT_MS, 250).
-define(PROBE_TIMEOUT_MS, 5000).
-define(STALE_PROBE_MS, (?PROBE_TIMEOUT_MS + 4 * ?INTERVAL_MS)).
-spec start_link() -> gen_server:start_ret().
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
-spec is_degraded(pid()) -> boolean().
is_degraded(Pid) when node(Pid) =:= node() ->
-spec is_overloaded(pid()) -> boolean().
is_overloaded(Pid) when node(Pid) =:= node() ->
case lookup(Pid) of
{Pid, _GuildId, Degraded, _Targets, _Pending} -> Degraded;
undefined -> false
{Pid, _GuildId, _Degraded, _Targets, Pending} ->
overloaded(Pending, erlang:monotonic_time(millisecond));
undefined ->
false
end;
is_degraded(Pid) ->
try erpc:call(node(Pid), ?MODULE, is_degraded, [Pid], ?REMOTE_LOOKUP_TIMEOUT_MS) of
is_overloaded(Pid) ->
try erpc:call(node(Pid), ?MODULE, is_overloaded, [Pid], ?REMOTE_LOOKUP_TIMEOUT_MS) of
true -> true;
_ -> false
catch
@@ -119,14 +123,8 @@ handle_call(_, _, State) ->
handle_cast({register, Pid}, State) when is_pid(Pid), node(Pid) =:= node() ->
{noreply, track_guild(Pid, State)};
handle_cast({pong, Pid, Ref, HandledAt}, State) when is_pid(Pid), is_integer(HandledAt) ->
case lookup(Pid) of
{Pid, GuildId, Degraded, Targets, {Ref, SentAt}} ->
NewDegraded = next_degraded(Degraded, max(0, HandledAt - SentAt)),
update({Pid, GuildId, Degraded, Targets, undefined}, NewDegraded),
{noreply, State};
_ ->
{noreply, State}
end;
ok = pong(Pid, Ref, HandledAt, erlang:monotonic_time(millisecond)),
{noreply, State};
handle_cast({current, Pid, SessionPid}, State) when is_pid(Pid), is_pid(SessionPid) ->
case lookup(Pid) of
{Pid, GuildId, Degraded, _Targets, _Pending} ->
@@ -186,20 +184,49 @@ check_guild(Pid, Now) ->
case lookup(Pid) of
{Pid, GuildId, Degraded, Targets, undefined} ->
case should_probe(Pid, Degraded) of
true ->
Ref = make_ref(),
ets:insert(?TABLE, {Pid, GuildId, Degraded, Targets, {Ref, Now}}),
Pid ! {guild_health_probe, Ref},
ok;
false ->
ok
true -> update({Pid, GuildId, Degraded, Targets, probe(Pid, Now, 0)}, Degraded);
false -> ok
end;
{Pid, _, Degraded, _, {_Ref, SentAt}} = Entry ->
{Pid, GuildId, Degraded, Targets, {_Ref, SentAt, Lag}} when
Now - SentAt >= ?PROBE_TIMEOUT_MS
->
Pending = probe(Pid, Now, max(Lag, Now - SentAt)),
update({Pid, GuildId, Degraded, Targets, Pending}, true);
{Pid, _, Degraded, _, {_Ref, SentAt, _Lag}} = Entry ->
update(Entry, Degraded orelse Now - SentAt > ?DEGRADED_MS);
_ ->
ok
end.
-spec pong(pid(), reference(), integer(), integer()) -> ok.
pong(Pid, Ref, HandledAt, Now) ->
case lookup(Pid) of
{Pid, GuildId, Degraded, Targets, {Ref, SentAt, _Lag}} ->
Delay = max(0, HandledAt - SentAt),
NewDegraded = next_degraded(Degraded, Delay),
Pending = next_pending(Pid, NewDegraded, Delay, Now),
update({Pid, GuildId, Degraded, Targets, Pending}, NewDegraded);
_ ->
ok
end.
-spec next_pending(pid(), boolean(), non_neg_integer(), integer()) ->
{reference(), integer(), non_neg_integer()} | undefined.
next_pending(Pid, true, Delay, Now) -> probe(Pid, Now, Delay);
next_pending(_Pid, false, _Delay, _Now) -> undefined.
-spec probe(pid(), integer(), non_neg_integer()) -> {reference(), integer(), non_neg_integer()}.
probe(Pid, Now, Lag) ->
Ref = make_ref(),
Pid ! {guild_health_probe, Ref},
{Ref, Now, Lag}.
-spec overloaded(term(), integer()) -> boolean().
overloaded({_Ref, SentAt, Lag}, Now) when Now - SentAt =< ?STALE_PROBE_MS ->
max(Lag, Now - SentAt) > ?DEGRADED_MS;
overloaded(_Pending, _Now) ->
false.
-spec should_probe(pid(), boolean()) -> boolean().
should_probe(_Pid, true) ->
true;
@@ -273,7 +300,7 @@ hysteresis_test() ->
unreachable_remote_pid_is_unknown_test() ->
Pid = binary_to_term(<<131, 88, 119, 12, "fake@nowhere", 1:32, 0:32, 1:32>>),
?assertNotEqual(node(), node(Pid)),
{ElapsedUs, Result} = timer:tc(?MODULE, is_degraded, [Pid]),
{ElapsedUs, Result} = timer:tc(?MODULE, is_overloaded, [Pid]),
?assertNot(Result),
?assert(ElapsedUs < 1000000).
@@ -291,23 +318,22 @@ pending_probe_is_bounded_and_recovers_only_after_fresh_reply_test() ->
try
Pid ! queued_work,
check_guild(Pid, 100),
{Pid, 42, false, Targets, {Ref, 100}} = lookup(Pid),
{Pid, 42, false, Targets, {Ref, 100, 0}} = lookup(Pid),
check_guild(Pid, 2201),
?assert(is_degraded(Pid)),
{Pid, 42, true, Targets, {Ref, 100, 0}} = lookup(Pid),
receive
{'$gen_cast', {guild_health, 42, Pid, true}} -> ok
after 100 -> ?assert(false)
end,
check_guild(Pid, 5000),
?assertEqual({message_queue_len, 2}, process_info(Pid, message_queue_len)),
{noreply, #{}} = handle_cast({pong, Pid, Ref, 5000}, #{}),
?assert(is_degraded(Pid)),
check_guild(Pid, 5100),
{Pid, 42, true, Targets, {Ref2, 5100}} = lookup(Pid),
{noreply, #{}} = handle_cast({pong, Pid, Ref, 5110}, #{}),
?assert(is_degraded(Pid)),
{noreply, #{}} = handle_cast({pong, Pid, Ref2, 5110}, #{}),
?assertNot(is_degraded(Pid)),
ok = pong(Pid, Ref, 5000, 5000),
{Pid, 42, true, Targets, {Ref2, 5000, 4900}} = lookup(Pid),
?assertEqual({message_queue_len, 3}, process_info(Pid, message_queue_len)),
ok = pong(Pid, Ref, 5110, 5110),
{Pid, 42, true, Targets, {Ref2, 5000, 4900}} = lookup(Pid),
ok = pong(Pid, Ref2, 5110, 5110),
?assertEqual({Pid, 42, false, Targets, undefined}, lookup(Pid)),
receive
{'$gen_cast', {guild_health, 42, Pid, false}} -> ok
after 100 -> ?assert(false)
@@ -318,6 +344,57 @@ pending_probe_is_bounded_and_recovers_only_after_fresh_reply_test() ->
ets:delete(Targets)
end.
lost_pong_is_reprobed_and_the_guild_recovers_test() ->
ok = guild_ets_owner:ensure_table(?TABLE, [named_table, public, set]),
Targets = ets:new(health_lost_pong_targets, [set]),
ets:insert(Targets, {<<"session">>, self()}),
Pid = spawn(fun() ->
receive
stop -> ok
end
end),
ets:insert(?TABLE, {Pid, 44, false, Targets, undefined}),
try
Pid ! queued_work,
check_guild(Pid, 0),
{Pid, 44, false, Targets, {Lost, 0, 0}} = lookup(Pid),
check_guild(Pid, 2001),
receive
{'$gen_cast', {guild_health, 44, Pid, true}} -> ok
after 100 -> ?assert(false)
end,
check_guild(Pid, 4999),
?assertEqual({message_queue_len, 2}, process_info(Pid, message_queue_len)),
check_guild(Pid, 5000),
{Pid, 44, true, Targets, {Fresh, 5000, 5000} = Pending} = lookup(Pid),
?assertNotEqual(Lost, Fresh),
?assertEqual({message_queue_len, 3}, process_info(Pid, message_queue_len)),
?assert(overloaded(Pending, 5001)),
ok = pong(Pid, Fresh, 5020, 5020),
?assertEqual({Pid, 44, false, Targets, undefined}, lookup(Pid)),
?assertNot(is_overloaded(Pid)),
receive
{'$gen_cast', {guild_health, 44, Pid, false}} -> ok
after 100 -> ?assert(false)
end,
ok = pong(Pid, Lost, 5030, 5030),
?assertEqual({Pid, 44, false, Targets, undefined}, lookup(Pid))
after
Pid ! stop,
ets:delete(?TABLE, Pid),
ets:delete(Targets)
end.
shedding_needs_fresh_evidence_of_lag_test() ->
Ref = make_ref(),
?assertNot(overloaded(undefined, 10000)),
?assertNot(overloaded({Ref, 10000, 0}, 12000)),
?assert(overloaded({Ref, 10000, 0}, 12001)),
?assertNot(overloaded({Ref, 10000, 1500}, 10100)),
?assert(overloaded({Ref, 10000, 2500}, 10100)),
?assert(overloaded({Ref, 10000, 5000}, 10000 + ?STALE_PROBE_MS)),
?assertNot(overloaded({Ref, 10000, 5000}, 10001 + ?STALE_PROBE_MS)).
session_targets_follow_replacement_and_removal_test() ->
Tab = ets:new(health_test_targets, [set]),
State = #{guild_health_sessions => Tab, sessions => #{<<"s">> => #{pid => self()}}},

Some files were not shown because too many files have changed in this diff Show More