fix: Fix multiple bugs with new authentication flow
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect
|
||||
} from 'vitest';
|
||||
import { aggregateComponentCountsByDomain, detectSuspectedComponentLeaks } from './component-tree.rules';
|
||||
|
||||
describe('aggregateComponentCountsByDomain', () => {
|
||||
it('groups component counts under inferred domains', () => {
|
||||
expect(aggregateComponentCountsByDomain({
|
||||
VoiceChannelPanelComponent: 2,
|
||||
DmChatComponent: 1,
|
||||
App: 1
|
||||
})).toEqual({
|
||||
'voice-connection': 2,
|
||||
'direct-message': 1,
|
||||
core: 1
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectSuspectedComponentLeaks', () => {
|
||||
it('flags components that exceed the expected live count', () => {
|
||||
expect(detectSuspectedComponentLeaks({
|
||||
DmChatComponent: 3,
|
||||
App: 1
|
||||
}, {
|
||||
DmChatComponent: 0,
|
||||
App: 1
|
||||
})).toEqual([
|
||||
{
|
||||
name: 'DmChatComponent',
|
||||
count: 3,
|
||||
expected: 0
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty list when counts are within expectations', () => {
|
||||
expect(detectSuspectedComponentLeaks({
|
||||
App: 1
|
||||
}, {
|
||||
App: 1
|
||||
})).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { mapComponentNameToDomain } from './domain-mapping.rules';
|
||||
|
||||
export interface SuspectedComponentLeak {
|
||||
name: string;
|
||||
count: number;
|
||||
expected: number;
|
||||
}
|
||||
|
||||
export function aggregateComponentCountsByDomain(
|
||||
componentCounts: Record<string, number>
|
||||
): Record<string, number> {
|
||||
const domains: Record<string, number> = {};
|
||||
|
||||
for (const [componentName, count] of Object.entries(componentCounts)) {
|
||||
const domain = mapComponentNameToDomain(componentName);
|
||||
|
||||
domains[domain] = (domains[domain] ?? 0) + count;
|
||||
}
|
||||
|
||||
return domains;
|
||||
}
|
||||
|
||||
export function detectSuspectedComponentLeaks(
|
||||
componentCounts: Record<string, number>,
|
||||
expectedCounts: Record<string, number>
|
||||
): SuspectedComponentLeak[] {
|
||||
const leaks: SuspectedComponentLeak[] = [];
|
||||
|
||||
for (const [name, count] of Object.entries(componentCounts)) {
|
||||
const expected = expectedCounts[name] ?? 0;
|
||||
|
||||
if (count > expected) {
|
||||
leaks.push({ name, count, expected });
|
||||
}
|
||||
}
|
||||
|
||||
return leaks.sort((left, right) => right.count - left.count);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ApplicationRef } from '@angular/core';
|
||||
import { aggregateComponentCountsByDomain, detectSuspectedComponentLeaks } from './component-tree.rules';
|
||||
|
||||
const DEFAULT_DOM_SCAN_BUDGET = 400;
|
||||
|
||||
interface NgDebugApi {
|
||||
getComponent?: (element: Element) => { constructor: { name: string } } | null;
|
||||
}
|
||||
|
||||
export interface ComponentTreeScanResult {
|
||||
components: Record<string, number>;
|
||||
domains: Record<string, number>;
|
||||
suspectedLeaks: ReturnType<typeof detectSuspectedComponentLeaks>;
|
||||
scannedNodes: number;
|
||||
scanMode: 'application-ref' | 'ng-global';
|
||||
}
|
||||
|
||||
export function scanComponentTree(
|
||||
appRef: ApplicationRef,
|
||||
expectedCounts: Record<string, number>,
|
||||
options: { domScanBudget?: number } = {}
|
||||
): ComponentTreeScanResult {
|
||||
const domScanBudget = options.domScanBudget ?? DEFAULT_DOM_SCAN_BUDGET;
|
||||
const ngApi = (globalThis as { ng?: NgDebugApi }).ng;
|
||||
const components: Record<string, number> = {};
|
||||
|
||||
let scannedNodes = 0;
|
||||
let scanMode: ComponentTreeScanResult['scanMode'] = 'application-ref';
|
||||
|
||||
if (ngApi?.getComponent && typeof document !== 'undefined') {
|
||||
scanMode = 'ng-global';
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
|
||||
|
||||
while (walker.nextNode() && scannedNodes < domScanBudget) {
|
||||
scannedNodes += 1;
|
||||
|
||||
try {
|
||||
const component = ngApi.getComponent?.(walker.currentNode as Element) as {
|
||||
constructor: { name: string };
|
||||
} | null;
|
||||
|
||||
if (!component) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = component.constructor.name;
|
||||
|
||||
components[name] = (components[name] ?? 0) + 1;
|
||||
} catch {
|
||||
// Ignore elements that are not component hosts.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const componentRef of appRef.components) {
|
||||
const name = componentRef.componentType.name;
|
||||
|
||||
components[name] = (components[name] ?? 0) + 1;
|
||||
scannedNodes += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
components,
|
||||
domains: aggregateComponentCountsByDomain(components),
|
||||
suspectedLeaks: detectSuspectedComponentLeaks(components, expectedCounts),
|
||||
scannedNodes,
|
||||
scanMode
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
EnvironmentInjector,
|
||||
inject,
|
||||
runInInjectionContext
|
||||
} from '@angular/core';
|
||||
import type { ElectronApi } from '../../core/platform/electron/electron-api.models';
|
||||
import { PerfDiagnosticsCollector, publishRendererDiagnosticsSample } from './diagnostics.collector';
|
||||
import type { PerfDiagEntry, PerfDiagReporter } from './diagnostics.models';
|
||||
|
||||
const SAMPLE_INTERVAL_MS = 10_000;
|
||||
|
||||
let started = false;
|
||||
let sampleTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export async function bootstrapPerfDiagnostics(
|
||||
api: ElectronApi,
|
||||
injector: EnvironmentInjector
|
||||
): Promise<void> {
|
||||
const reportSample = api.reportPerfDiagSample;
|
||||
|
||||
if (started || !api.isPerfDiagEnabled || !reportSample) {
|
||||
return;
|
||||
}
|
||||
|
||||
let enabled = false;
|
||||
|
||||
try {
|
||||
enabled = await api.isPerfDiagEnabled();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
started = true;
|
||||
|
||||
const reporter: PerfDiagReporter = {
|
||||
report: (entry: PerfDiagEntry) => reportSample(entry)
|
||||
};
|
||||
const runSample = (): void => {
|
||||
void runInInjectionContext(injector, async () => {
|
||||
try {
|
||||
const collector = inject(PerfDiagnosticsCollector);
|
||||
|
||||
await publishRendererDiagnosticsSample(reporter, collector);
|
||||
} catch {
|
||||
stopPerfDiagnosticsSampling();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
scheduleSample(runSample);
|
||||
sampleTimer = setInterval(() => scheduleSample(runSample), SAMPLE_INTERVAL_MS);
|
||||
|
||||
window.addEventListener('error', () => {
|
||||
void reporter.report({
|
||||
collectedAt: Date.now(),
|
||||
source: 'renderer',
|
||||
type: 'crash',
|
||||
payload: { scope: 'window-error' }
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', () => {
|
||||
void reporter.report({
|
||||
collectedAt: Date.now(),
|
||||
source: 'renderer',
|
||||
type: 'crash',
|
||||
payload: { scope: 'unhandled-rejection' }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleSample(runSample: () => void): void {
|
||||
const idle = (globalThis as {
|
||||
requestIdleCallback?: (handler: () => void, options?: { timeout: number }) => number;
|
||||
}).requestIdleCallback;
|
||||
|
||||
if (idle) {
|
||||
idle(() => runSample(), { timeout: SAMPLE_INTERVAL_MS });
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(runSample, 0);
|
||||
}
|
||||
|
||||
function stopPerfDiagnosticsSampling(): void {
|
||||
if (sampleTimer) {
|
||||
clearInterval(sampleTimer);
|
||||
sampleTimer = null;
|
||||
}
|
||||
|
||||
started = false;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import {
|
||||
ApplicationRef,
|
||||
inject,
|
||||
Injectable
|
||||
} from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Router } from '@angular/router';
|
||||
import type { AppState } from '../../store';
|
||||
import { mapStoreSliceToDomain } from './domain-mapping.rules';
|
||||
import { estimateStructuredBytes } from './state-size.rules';
|
||||
import { scanComponentTree } from './component-tree.scanner';
|
||||
import type { PerfDiagEntry, PerfDiagReporter } from './diagnostics.models';
|
||||
|
||||
const SAMPLE_BUDGET_MS = 8;
|
||||
|
||||
export interface RendererDiagnosticsSample {
|
||||
storeDomains: Record<string, number>;
|
||||
storeBytes: Record<string, number>;
|
||||
components: Record<string, number>;
|
||||
componentDomains: Record<string, number>;
|
||||
suspectedLeaks: { name: string; count: number; expected: number }[];
|
||||
heap: {
|
||||
usedJsHeapMb: number | null;
|
||||
totalJsHeapMb: number | null;
|
||||
};
|
||||
route: string | null;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PerfDiagnosticsCollector {
|
||||
private readonly appRef = inject(ApplicationRef);
|
||||
private readonly store = inject(Store<AppState>);
|
||||
private readonly router = inject(Router);
|
||||
private disabled = false;
|
||||
private readonly expectedComponentCounts: Record<string, number> = {
|
||||
App: 1
|
||||
};
|
||||
|
||||
collectSample(): RendererDiagnosticsSample | null {
|
||||
if (this.disabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startedAt = performance.now();
|
||||
|
||||
try {
|
||||
const state = this.collectStoreState();
|
||||
const storeBytes: Record<string, number> = {};
|
||||
const storeDomains: Record<string, number> = {};
|
||||
|
||||
for (const [sliceKey, sliceValue] of Object.entries(state)) {
|
||||
const bytes = estimateStructuredBytes(sliceValue, { maxNodes: 200, maxDepth: 5 });
|
||||
const domain = mapStoreSliceToDomain(sliceKey);
|
||||
|
||||
storeBytes[sliceKey] = bytes;
|
||||
storeDomains[domain] = (storeDomains[domain] ?? 0) + bytes;
|
||||
}
|
||||
|
||||
const componentScan = scanComponentTree(this.appRef, this.expectedComponentCounts);
|
||||
const heap = readJsHeapSnapshot();
|
||||
|
||||
return {
|
||||
storeDomains,
|
||||
storeBytes,
|
||||
components: componentScan.components,
|
||||
componentDomains: componentScan.domains,
|
||||
suspectedLeaks: componentScan.suspectedLeaks,
|
||||
heap,
|
||||
route: this.router.url,
|
||||
durationMs: performance.now() - startedAt
|
||||
};
|
||||
} catch {
|
||||
this.disabled = true;
|
||||
return null;
|
||||
} finally {
|
||||
if (performance.now() - startedAt > SAMPLE_BUDGET_MS) {
|
||||
// Skip future samples if this collector cannot stay within budget.
|
||||
this.disabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildEntries(sample: RendererDiagnosticsSample): PerfDiagEntry[] {
|
||||
const collectedAt = Date.now();
|
||||
|
||||
return [
|
||||
{
|
||||
collectedAt,
|
||||
source: 'renderer',
|
||||
type: 'store',
|
||||
payload: {
|
||||
domains: sample.storeDomains,
|
||||
slices: sample.storeBytes,
|
||||
route: sample.route
|
||||
}
|
||||
},
|
||||
{
|
||||
collectedAt,
|
||||
source: 'renderer',
|
||||
type: 'components',
|
||||
payload: {
|
||||
components: sample.components,
|
||||
domains: sample.componentDomains,
|
||||
suspectedLeaks: sample.suspectedLeaks,
|
||||
route: sample.route,
|
||||
durationMs: sample.durationMs
|
||||
}
|
||||
},
|
||||
{
|
||||
collectedAt,
|
||||
source: 'renderer',
|
||||
type: 'heap',
|
||||
payload: {
|
||||
...sample.heap,
|
||||
route: sample.route
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
private collectStoreState(): AppState {
|
||||
let latest: AppState | undefined;
|
||||
|
||||
this.store.subscribe((state) => {
|
||||
latest = state;
|
||||
}).unsubscribe();
|
||||
|
||||
if (!latest) {
|
||||
throw new Error('Store state unavailable');
|
||||
}
|
||||
|
||||
return latest;
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishRendererDiagnosticsSample(
|
||||
reporter: PerfDiagReporter,
|
||||
collector: PerfDiagnosticsCollector
|
||||
): Promise<boolean> {
|
||||
const sample = collector.collectSample();
|
||||
|
||||
if (!sample) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const entries = collector.buildEntries(sample);
|
||||
|
||||
let reported = true;
|
||||
|
||||
for (const entry of entries) {
|
||||
const accepted = await reporter.report(entry);
|
||||
|
||||
if (!accepted) {
|
||||
reported = false;
|
||||
}
|
||||
}
|
||||
|
||||
return reported;
|
||||
}
|
||||
|
||||
function readJsHeapSnapshot(): { usedJsHeapMb: number | null; totalJsHeapMb: number | null } {
|
||||
const memory = (performance as Performance & {
|
||||
memory?: {
|
||||
usedJSHeapSize: number;
|
||||
totalJSHeapSize: number;
|
||||
};
|
||||
}).memory;
|
||||
|
||||
if (!memory) {
|
||||
return {
|
||||
usedJsHeapMb: null,
|
||||
totalJsHeapMb: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
usedJsHeapMb: roundMb(memory.usedJSHeapSize),
|
||||
totalJsHeapMb: roundMb(memory.totalJSHeapSize)
|
||||
};
|
||||
}
|
||||
|
||||
function roundMb(bytes: number): number {
|
||||
return Math.round((bytes / (1024 * 1024)) * 100) / 100;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export type PerfDiagSource = 'main' | 'renderer';
|
||||
|
||||
export type PerfDiagEntryType =
|
||||
| 'session'
|
||||
| 'process'
|
||||
| 'store'
|
||||
| 'components'
|
||||
| 'heap'
|
||||
| 'crash'
|
||||
| 'unresponsive';
|
||||
|
||||
export interface PerfDiagEntry {
|
||||
collectedAt: number;
|
||||
source: PerfDiagSource;
|
||||
type: PerfDiagEntryType;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PerfDiagReporter {
|
||||
report(entry: PerfDiagEntry): Promise<boolean>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect
|
||||
} from 'vitest';
|
||||
import { mapComponentNameToDomain, mapStoreSliceToDomain } from './domain-mapping.rules';
|
||||
|
||||
describe('mapStoreSliceToDomain', () => {
|
||||
it('maps known NgRx slices to domains', () => {
|
||||
expect(mapStoreSliceToDomain('messages')).toBe('chat');
|
||||
expect(mapStoreSliceToDomain('users')).toBe('users');
|
||||
expect(mapStoreSliceToDomain('rooms')).toBe('rooms');
|
||||
});
|
||||
|
||||
it('falls back to the slice name for unknown keys', () => {
|
||||
expect(mapStoreSliceToDomain('custom')).toBe('custom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapComponentNameToDomain', () => {
|
||||
it('maps component class prefixes to domains', () => {
|
||||
expect(mapComponentNameToDomain('VoiceChannelPanelComponent')).toBe('voice-connection');
|
||||
expect(mapComponentNameToDomain('DmChatComponent')).toBe('direct-message');
|
||||
expect(mapComponentNameToDomain('PluginStoreComponent')).toBe('plugins');
|
||||
});
|
||||
|
||||
it('falls back to core for unknown components', () => {
|
||||
expect(mapComponentNameToDomain('App')).toBe('core');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
const STORE_SLICE_DOMAINS: Record<string, string> = {
|
||||
messages: 'chat',
|
||||
users: 'users',
|
||||
rooms: 'rooms'
|
||||
};
|
||||
const COMPONENT_PREFIX_DOMAINS: [string, string][] = [
|
||||
['Voice', 'voice-connection'],
|
||||
['ScreenShare', 'screen-share'],
|
||||
['Dm', 'direct-message'],
|
||||
['DirectCall', 'direct-call'],
|
||||
['DirectMessage', 'direct-message'],
|
||||
['Plugin', 'plugins'],
|
||||
['Chat', 'chat'],
|
||||
['Message', 'chat'],
|
||||
['Server', 'server-directory'],
|
||||
['Room', 'rooms'],
|
||||
['Theme', 'theme'],
|
||||
['Emoji', 'custom-emoji'],
|
||||
['Notification', 'notifications'],
|
||||
['Game', 'game-activity'],
|
||||
['Profile', 'profile-avatar'],
|
||||
['Attachment', 'attachment'],
|
||||
['Auth', 'authentication'],
|
||||
['Login', 'authentication'],
|
||||
['Register', 'authentication']
|
||||
];
|
||||
|
||||
export function mapStoreSliceToDomain(sliceKey: string): string {
|
||||
return STORE_SLICE_DOMAINS[sliceKey] ?? sliceKey;
|
||||
}
|
||||
|
||||
export function mapComponentNameToDomain(componentName: string): string {
|
||||
if (!componentName || componentName === 'App') {
|
||||
return 'core';
|
||||
}
|
||||
|
||||
for (const [prefix, domain] of COMPONENT_PREFIX_DOMAINS) {
|
||||
if (componentName.startsWith(prefix)) {
|
||||
return domain;
|
||||
}
|
||||
}
|
||||
|
||||
if (componentName.endsWith('Component')) {
|
||||
return 'features';
|
||||
}
|
||||
|
||||
return 'core';
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect
|
||||
} from 'vitest';
|
||||
import { estimateStructuredBytes } from './state-size.rules';
|
||||
|
||||
describe('estimateStructuredBytes', () => {
|
||||
it('returns zero for nullish values', () => {
|
||||
expect(estimateStructuredBytes(null)).toBe(0);
|
||||
expect(estimateStructuredBytes(undefined)).toBe(0);
|
||||
});
|
||||
|
||||
it('estimates primitive and shallow object sizes', () => {
|
||||
expect(estimateStructuredBytes('hello')).toBeGreaterThan(0);
|
||||
expect(estimateStructuredBytes({ a: 1, b: 'two' })).toBeGreaterThan(estimateStructuredBytes({ a: 1 }));
|
||||
});
|
||||
|
||||
it('stops walking once the node budget is exhausted', () => {
|
||||
const deep = {
|
||||
level1: {
|
||||
level2: {
|
||||
level3: {
|
||||
payload: 'value'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const full = estimateStructuredBytes(deep, { maxNodes: 50 });
|
||||
const shallow = estimateStructuredBytes(deep, { maxNodes: 2 });
|
||||
|
||||
expect(shallow).toBeLessThan(full);
|
||||
});
|
||||
});
|
||||
147
toju-app/src/app/infrastructure/diagnostics/state-size.rules.ts
Normal file
147
toju-app/src/app/infrastructure/diagnostics/state-size.rules.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
export interface StructuredByteEstimateOptions {
|
||||
maxNodes?: number;
|
||||
maxDepth?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_NODES = 250;
|
||||
const DEFAULT_MAX_DEPTH = 6;
|
||||
|
||||
export function estimateStructuredBytes(
|
||||
value: unknown,
|
||||
options: StructuredByteEstimateOptions = {}
|
||||
): number {
|
||||
const maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES;
|
||||
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
||||
const budget = { remaining: maxNodes };
|
||||
|
||||
return walkValue(value, 0, maxDepth, budget);
|
||||
}
|
||||
|
||||
function walkValue(
|
||||
value: unknown,
|
||||
depth: number,
|
||||
maxDepth: number,
|
||||
budget: { remaining: number }
|
||||
): number {
|
||||
if (budget.remaining <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
budget.remaining -= 1;
|
||||
|
||||
const primitiveBytes = estimatePrimitiveBytes(value);
|
||||
|
||||
if (primitiveBytes != null) {
|
||||
return primitiveBytes;
|
||||
}
|
||||
|
||||
if (depth >= maxDepth) {
|
||||
return 32;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return walkArray(value, depth, maxDepth, budget);
|
||||
}
|
||||
|
||||
if (value instanceof Map || value instanceof Set) {
|
||||
return walkIterable(value, depth, maxDepth, budget);
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return walkObject(value as Record<string, unknown>, depth, maxDepth, budget);
|
||||
}
|
||||
|
||||
return 16;
|
||||
}
|
||||
|
||||
function estimatePrimitiveBytes(value: unknown): number | null {
|
||||
if (value == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value.length * 2;
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return 8;
|
||||
}
|
||||
|
||||
if (typeof value === 'bigint') {
|
||||
return 16;
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return 24;
|
||||
}
|
||||
|
||||
if (ArrayBuffer.isView(value)) {
|
||||
return value.byteLength;
|
||||
}
|
||||
|
||||
if (value instanceof ArrayBuffer) {
|
||||
return value.byteLength;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function walkArray(
|
||||
value: unknown[],
|
||||
depth: number,
|
||||
maxDepth: number,
|
||||
budget: { remaining: number }
|
||||
): number {
|
||||
let total = 16;
|
||||
|
||||
for (const item of value) {
|
||||
if (budget.remaining <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
total += walkValue(item, depth + 1, maxDepth, budget);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
function walkIterable(
|
||||
value: Map<unknown, unknown> | Set<unknown>,
|
||||
depth: number,
|
||||
maxDepth: number,
|
||||
budget: { remaining: number }
|
||||
): number {
|
||||
let total = 32;
|
||||
let walked = 0;
|
||||
|
||||
for (const item of value) {
|
||||
if (budget.remaining <= 0 || walked >= 25) {
|
||||
break;
|
||||
}
|
||||
|
||||
walked += 1;
|
||||
total += walkValue(item, depth + 1, maxDepth, budget);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
function walkObject(
|
||||
value: Record<string, unknown>,
|
||||
depth: number,
|
||||
maxDepth: number,
|
||||
budget: { remaining: number }
|
||||
): number {
|
||||
let total = 16;
|
||||
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
if (budget.remaining <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
total += key.length * 2;
|
||||
total += walkValue(nested, depth + 1, maxDepth, budget);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import '@angular/compiler';
|
||||
import { Injector, runInInjectionContext } from '@angular/core';
|
||||
import {
|
||||
beforeEach,
|
||||
@@ -13,6 +14,21 @@ import { CapacitorDatabaseService } from './capacitor-database.service';
|
||||
import { DatabaseService } from './database.service';
|
||||
import { ElectronDatabaseService } from './electron-database.service';
|
||||
|
||||
function installLocalStorageMock(): void {
|
||||
const store = new Map<string, string>();
|
||||
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => store.set(key, String(value)),
|
||||
removeItem: (key: string) => store.delete(key),
|
||||
clear: () => store.clear(),
|
||||
key: (index: number) => Array.from(store.keys())[index] ?? null,
|
||||
get length() {
|
||||
return store.size;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('DatabaseService', () => {
|
||||
let browserDatabase: {
|
||||
getBansForRoom: ReturnType<typeof vi.fn>;
|
||||
@@ -28,6 +44,9 @@ describe('DatabaseService', () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
installLocalStorageMock();
|
||||
localStorage.clear();
|
||||
|
||||
browserDatabase = {
|
||||
getBansForRoom: vi.fn(() => Promise.resolve([])),
|
||||
initialize: vi.fn(() => Promise.resolve())
|
||||
@@ -69,6 +88,56 @@ describe('DatabaseService', () => {
|
||||
expect(service.isReady()).toBe(true);
|
||||
});
|
||||
|
||||
it('rechecks backend initialization when the user scope changes during an in-flight initialize call', async () => {
|
||||
let finishInitialInitialize!: () => void;
|
||||
|
||||
browserDatabase.initialize = vi.fn()
|
||||
.mockImplementationOnce(() => new Promise<void>((resolve) => {
|
||||
finishInitialInitialize = resolve;
|
||||
}))
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
localStorage.setItem('metoyou_currentUserId', 'user-a');
|
||||
|
||||
const service = createService({ isBrowser: true, isElectron: false, isCapacitor: false });
|
||||
const initialInitialize = service.initialize();
|
||||
|
||||
localStorage.setItem('metoyou_currentUserId', 'user-b');
|
||||
const initializeAfterScopeChange = service.initialize();
|
||||
|
||||
expect(browserDatabase.initialize).toHaveBeenCalledTimes(1);
|
||||
|
||||
finishInitialInitialize();
|
||||
await Promise.all([initialInitialize, initializeAfterScopeChange]);
|
||||
|
||||
expect(browserDatabase.initialize).toHaveBeenCalledTimes(2);
|
||||
expect(service.isReady()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not reinitialize the browser backend for repeated reads in the same user scope', async () => {
|
||||
const service = createService({ isBrowser: true, isElectron: false, isCapacitor: false });
|
||||
|
||||
await service.getBansForRoom('room-1');
|
||||
await service.getBansForRoom('room-2');
|
||||
|
||||
expect(browserDatabase.initialize).toHaveBeenCalledTimes(1);
|
||||
expect(browserDatabase.getBansForRoom).toHaveBeenCalledWith('room-1');
|
||||
expect(browserDatabase.getBansForRoom).toHaveBeenCalledWith('room-2');
|
||||
});
|
||||
|
||||
it('reinitializes the browser backend when the stored user scope changes', async () => {
|
||||
localStorage.setItem('metoyou_currentUserId', 'user-a');
|
||||
|
||||
const service = createService({ isBrowser: true, isElectron: false, isCapacitor: false });
|
||||
|
||||
await service.getBansForRoom('room-1');
|
||||
localStorage.setItem('metoyou_currentUserId', 'user-b');
|
||||
await service.getBansForRoom('room-2');
|
||||
|
||||
expect(browserDatabase.initialize).toHaveBeenCalledTimes(2);
|
||||
expect(browserDatabase.getBansForRoom).toHaveBeenCalledWith('room-2');
|
||||
});
|
||||
|
||||
it('routes Capacitor shells to native SQLite instead of IndexedDB', async () => {
|
||||
const service = createService({ isBrowser: false, isElectron: false, isCapacitor: true });
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../../shared-kernel';
|
||||
import type { ChatAttachmentMeta, CustomEmoji } from '../../shared-kernel';
|
||||
import { PlatformService } from '../../core/platform';
|
||||
import { getStoredCurrentUserId } from '../../core/storage/current-user-storage';
|
||||
import { BrowserDatabaseService } from './browser-database.service';
|
||||
import { CapacitorDatabaseService } from './capacitor-database.service';
|
||||
import { resolveDatabaseBackend } from './database-backend.rules';
|
||||
@@ -42,6 +43,7 @@ export class DatabaseService {
|
||||
private readonly capacitorDb = inject(CapacitorDatabaseService);
|
||||
private readonly electronDb = inject(ElectronDatabaseService);
|
||||
private initializationPromise: Promise<void> | null = null;
|
||||
private validatedUserScope: string | null | undefined;
|
||||
|
||||
/** Reactive flag: `true` once {@link initialize} has completed. */
|
||||
isReady = signal(false);
|
||||
@@ -66,8 +68,15 @@ export class DatabaseService {
|
||||
|
||||
/** Initialise the platform-specific database. */
|
||||
async initialize(): Promise<void> {
|
||||
const userScope = getStoredCurrentUserId();
|
||||
|
||||
if (this.initializationPromise) {
|
||||
await this.initializationPromise;
|
||||
|
||||
if (this.isReady() && this.validatedUserScope === userScope) {
|
||||
return;
|
||||
}
|
||||
} else if (this.isReady() && this.validatedUserScope === userScope) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -75,6 +84,7 @@ export class DatabaseService {
|
||||
|
||||
this.initializationPromise = backend.initialize()
|
||||
.then(() => {
|
||||
this.validatedUserScope = userScope;
|
||||
this.isReady.set(true);
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -85,8 +95,11 @@ export class DatabaseService {
|
||||
}
|
||||
|
||||
private async ensureReady(): Promise<void> {
|
||||
if (this.isReady())
|
||||
const userScope = getStoredCurrentUserId();
|
||||
|
||||
if (this.isReady() && this.validatedUserScope === userScope) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
@@ -164,7 +164,11 @@ The browser also sends a lightweight `keepalive` message on the signaling socket
|
||||
|
||||
### Server-side connection hygiene
|
||||
|
||||
Browsers do not reliably fire WebSocket close events during page refresh or navigation (especially Chromium). The server's `handleIdentify` now closes any existing connection that shares the same `oderId` but a different `connectionId`. This guarantees `findUserByOderId` always routes offers and presence events to the freshest socket, eliminating a class of bugs where signaling messages landed on a dead tab's socket and were silently lost.
|
||||
Browsers do not reliably fire WebSocket close events during page refresh or navigation (especially Chromium). On `identify`, the server evicts stale sockets that share the same `(oderId, connectionScope, clientInstanceId)` tuple so a refreshed tab does not leave a zombie connection behind.
|
||||
|
||||
Multi-device sessions keep **multiple** open connections for the same `oderId` (different `clientInstanceId` values). Server broadcasts exclude only the sending **connection id**, not the whole identity, so chat/typing/voice-state updates reach every logged-in device. Presence `user_joined` / `user_left` broadcasts still exclude the whole identity so other users never see duplicate join/leave events.
|
||||
|
||||
RTC offers/answers/ICE are routed to the connection marked `voiceActive` for the target user (fallback: any open connection). Voice ownership is tracked per connection from `voice_state` payloads that include `clientInstanceId`.
|
||||
|
||||
Join and leave broadcasts are also identity-aware: `handleJoinServer` only broadcasts `user_joined` when the identity is genuinely new to that server (not just a second WebSocket connection for the same user), and `handleLeaveServer` / dead-connection cleanup only broadcast `user_left` when no other open connection for that identity remains in the server. The `user_left` payload includes `serverIds` listing the rooms the identity still belongs to, so the client can subtract correctly without over-removing.
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import { SignalingManager } from './signaling/signaling.manager';
|
||||
import { SignalingTransportHandler } from './signaling/signaling-transport-handler';
|
||||
import { WebRtcStateController } from './state/webrtc-state-controller';
|
||||
import { AuthTokenStoreService } from '../../domains/authentication';
|
||||
import { ClientInstanceService } from '../../core/platform/client-instance.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -51,6 +52,7 @@ export class WebRTCService implements OnDestroy {
|
||||
private readonly screenShareSourcePicker = inject(ScreenShareSourcePickerService);
|
||||
private readonly iceServerSettings = inject(IceServerSettingsService);
|
||||
private readonly authTokenStore = inject(AuthTokenStoreService);
|
||||
private readonly clientInstance = inject(ClientInstanceService);
|
||||
|
||||
private readonly logger = new WebRTCLogger(() => this.debugging.enabled());
|
||||
private readonly state = new WebRtcStateController();
|
||||
@@ -161,7 +163,8 @@ export class WebRTCService implements OnDestroy {
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
},
|
||||
getClientInstanceId: () => this.clientInstance.getClientInstanceId()
|
||||
});
|
||||
|
||||
// Now wire up cross-references (all managers are instantiated)
|
||||
@@ -691,11 +694,17 @@ export class WebRTCService implements OnDestroy {
|
||||
}
|
||||
|
||||
private relayBroadcastEvent(event: ChatEvent): void {
|
||||
const clientInstanceId = this.clientInstance.getClientInstanceId();
|
||||
|
||||
if (event.type === 'chat-message' && event.message?.roomId) {
|
||||
this.signalingTransportHandler.sendRawMessage({
|
||||
type: 'chat_message',
|
||||
serverId: event.message.roomId,
|
||||
message: event.message
|
||||
message: {
|
||||
...event.message,
|
||||
clientInstanceId
|
||||
},
|
||||
clientInstanceId
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -705,11 +714,27 @@ export class WebRTCService implements OnDestroy {
|
||||
this.signalingTransportHandler.sendRawMessage({
|
||||
...event,
|
||||
type: 'voice_state',
|
||||
serverId: event.voiceState.serverId
|
||||
serverId: event.voiceState.serverId,
|
||||
voiceState: {
|
||||
...event.voiceState,
|
||||
clientInstanceId
|
||||
},
|
||||
clientInstanceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
requestVoiceClientTakeover(): void {
|
||||
this.signalingTransportHandler.sendRawMessage({
|
||||
type: 'voice_client_takeover',
|
||||
clientInstanceId: this.clientInstance.getClientInstanceId()
|
||||
});
|
||||
}
|
||||
|
||||
getClientInstanceId(): string {
|
||||
return this.clientInstance.getClientInstanceId();
|
||||
}
|
||||
|
||||
/** Disconnect from the signaling server and clean up all state. */
|
||||
disconnect(): void {
|
||||
this.leaveRoom();
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface IdentifyCredentials {
|
||||
profileUpdatedAt?: number;
|
||||
/** Public signal-server URL where this user registered. */
|
||||
homeSignalServerUrl?: string;
|
||||
/** Stable per-install client id used for multi-device session routing. */
|
||||
clientInstanceId?: string;
|
||||
}
|
||||
|
||||
/** Last-joined server info, used for reconnection. */
|
||||
@@ -76,4 +78,6 @@ export interface VoiceStateSnapshot {
|
||||
roomId?: string;
|
||||
/** The voice channel server ID, if applicable. */
|
||||
serverId?: string;
|
||||
/** Install-scoped client id that owns active voice for this snapshot. */
|
||||
clientInstanceId?: string;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface SignalingTransportHandlerDependencies<TMessage> {
|
||||
logger: WebRTCLogger;
|
||||
getLocalPeerId(): string;
|
||||
resolveSessionToken(signalUrl?: string): string | null;
|
||||
getClientInstanceId(): string;
|
||||
}
|
||||
|
||||
export class SignalingTransportHandler<TMessage> {
|
||||
@@ -201,13 +202,16 @@ export class SignalingTransportHandler<TMessage> {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientInstanceId = this.dependencies.getClientInstanceId();
|
||||
|
||||
this.lastIdentifyCredentials = {
|
||||
oderId,
|
||||
token,
|
||||
displayName: normalizedDisplayName,
|
||||
description: normalizedDescription,
|
||||
profileUpdatedAt: normalizedProfileUpdatedAt,
|
||||
homeSignalServerUrl: normalizedHomeSignalServerUrl
|
||||
homeSignalServerUrl: normalizedHomeSignalServerUrl,
|
||||
clientInstanceId
|
||||
};
|
||||
|
||||
if (signalUrl) {
|
||||
@@ -219,7 +223,8 @@ export class SignalingTransportHandler<TMessage> {
|
||||
description: normalizedDescription,
|
||||
profileUpdatedAt: normalizedProfileUpdatedAt,
|
||||
homeSignalServerUrl: normalizedHomeSignalServerUrl,
|
||||
connectionScope: signalUrl
|
||||
connectionScope: signalUrl,
|
||||
clientInstanceId
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -240,7 +245,8 @@ export class SignalingTransportHandler<TMessage> {
|
||||
description: normalizedDescription,
|
||||
profileUpdatedAt: normalizedProfileUpdatedAt,
|
||||
homeSignalServerUrl: normalizedHomeSignalServerUrl,
|
||||
connectionScope: managerSignalUrl
|
||||
connectionScope: managerSignalUrl,
|
||||
clientInstanceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,7 +379,8 @@ export class SignalingManager {
|
||||
description: credentials.description,
|
||||
profileUpdatedAt: credentials.profileUpdatedAt,
|
||||
homeSignalServerUrl: credentials.homeSignalServerUrl,
|
||||
connectionScope: this.lastSignalingUrl ?? undefined
|
||||
connectionScope: this.lastSignalingUrl ?? undefined,
|
||||
clientInstanceId: credentials.clientInstanceId
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user