Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3c2f01cc6 | |||
| dac5cb42a5 | |||
| 29032b5a36 | |||
| e75b4a38ed |
72
e2e/helpers/signal-manager.ts
Normal file
72
e2e/helpers/signal-manager.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
/** Read how many signaling managers are currently connected for this page. */
|
||||
export async function getConnectedSignalManagerCount(page: Page): Promise<number> {
|
||||
return page.evaluate(() => {
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
const host = document.querySelector('app-rooms-side-panel');
|
||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const realtime = component['realtime'] as {
|
||||
signalingTransportHandler?: {
|
||||
getConnectedSignalingManagers?: () => unknown[];
|
||||
};
|
||||
} | undefined;
|
||||
|
||||
return realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dual-signal setups create one RTCPeerConnection per remote peer per active
|
||||
* signaling manager, so the harness tracks `remotePeerCount * signalCount`
|
||||
* connected peer connections.
|
||||
*/
|
||||
export async function waitForConnectedRemotePeerMesh(
|
||||
page: Page,
|
||||
remotePeerCount: number,
|
||||
timeout = 45_000
|
||||
): Promise<void> {
|
||||
const signalCount = Math.max(await getConnectedSignalManagerCount(page), 1);
|
||||
const expectedCount = remotePeerCount * signalCount;
|
||||
const minimumCount = Math.max(remotePeerCount, expectedCount - signalCount);
|
||||
|
||||
await page.waitForFunction(
|
||||
(min) => ((window as unknown as {
|
||||
__rtcConnections?: RTCPeerConnection[];
|
||||
}).__rtcConnections ?? []).filter(
|
||||
(pc) => pc.connectionState === 'connected'
|
||||
).length >= min,
|
||||
minimumCount,
|
||||
{ timeout }
|
||||
);
|
||||
}
|
||||
|
||||
export async function getMinimumConnectedPeerMeshCount(
|
||||
page: Page,
|
||||
remotePeerCount: number
|
||||
): Promise<number> {
|
||||
const signalCount = Math.max(await getConnectedSignalManagerCount(page), 1);
|
||||
const expectedCount = remotePeerCount * signalCount;
|
||||
|
||||
return Math.max(remotePeerCount, expectedCount - signalCount);
|
||||
}
|
||||
|
||||
export async function waitForConnectedSignalManagerCount(
|
||||
page: Page,
|
||||
expectedCount: number,
|
||||
timeout = 30_000
|
||||
): Promise<void> {
|
||||
await expect.poll(async () => await getConnectedSignalManagerCount(page), {
|
||||
timeout,
|
||||
intervals: [500, 1_000]
|
||||
}).toBe(expectedCount);
|
||||
}
|
||||
49
e2e/helpers/voice-roster.ts
Normal file
49
e2e/helpers/voice-roster.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { type Page } from '@playwright/test';
|
||||
|
||||
/** Wait until the side-panel roster under a voice channel lists the expected user count. */
|
||||
export async function waitForVoiceRosterCount(
|
||||
page: Page,
|
||||
channelName: string,
|
||||
expectedCount: number,
|
||||
timeout = 45_000
|
||||
): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
({ expected, name }) => {
|
||||
const buttons = document.querySelectorAll(
|
||||
`app-rooms-side-panel button[data-channel-type="voice"][data-channel-name="${name}"]`
|
||||
);
|
||||
|
||||
for (const button of buttons) {
|
||||
const panel = button.closest('app-rooms-side-panel');
|
||||
|
||||
if (!panel || panel.getBoundingClientRect().width === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rosterDiv = button.nextElementSibling;
|
||||
|
||||
if (!rosterDiv) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const displayNames = new Set<string>();
|
||||
|
||||
rosterDiv.querySelectorAll('[appThemeNode="roomVoiceUserItem"] span.text-sm').forEach((element) => {
|
||||
const label = element.textContent?.trim();
|
||||
|
||||
if (label) {
|
||||
displayNames.add(label);
|
||||
}
|
||||
});
|
||||
|
||||
if (displayNames.size === expected) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
{ expected: expectedCount, name: channelName },
|
||||
{ timeout }
|
||||
);
|
||||
}
|
||||
@@ -8,10 +8,6 @@ interface ScreenShareMediaStream extends MediaStream {
|
||||
__isScreenShare?: boolean;
|
||||
}
|
||||
|
||||
function webRtcHarnessWindow(scope: Window = window): WebRtcTestHarnessWindow {
|
||||
return scope as unknown as WebRtcTestHarnessWindow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install RTCPeerConnection monkey-patch on a page BEFORE navigating.
|
||||
* Tracks all created peer connections and their remote tracks so tests
|
||||
@@ -32,7 +28,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
||||
source?: AudioScheduledSourceNode;
|
||||
drawIntervalId?: number;
|
||||
}[] = [];
|
||||
const harness = webRtcHarnessWindow();
|
||||
const harness = window as unknown as WebRtcTestHarnessWindow;
|
||||
|
||||
harness.__rtcConnections = connections;
|
||||
harness.__rtcDataChannels = dataChannels;
|
||||
@@ -160,6 +156,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
||||
|
||||
return resultStream;
|
||||
};
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
@@ -181,7 +178,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
||||
export async function installAutoResumeAudioContext(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const OrigAudioContext = window.AudioContext;
|
||||
const audioHarness = webRtcHarnessWindow();
|
||||
const audioHarness = window as unknown as WebRtcTestHarnessWindow;
|
||||
|
||||
audioHarness.AudioContext = function(this: AudioContext, ...args: AudioContextArgs) {
|
||||
const ctx: AudioContext = new OrigAudioContext(...args);
|
||||
@@ -211,7 +208,7 @@ export async function installAutoResumeAudioContext(page: Page): Promise<void> {
|
||||
|
||||
export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
() => webRtcHarnessWindow().__rtcConnections?.some(
|
||||
() => (window as unknown as WebRtcTestHarnessWindow).__rtcConnections?.some(
|
||||
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
||||
) ?? false,
|
||||
undefined,
|
||||
@@ -224,7 +221,7 @@ export async function waitForPeerConnected(page: Page, timeout = 30_000): Promis
|
||||
*/
|
||||
export async function isPeerStillConnected(page: Page): Promise<boolean> {
|
||||
return page.evaluate(
|
||||
() => webRtcHarnessWindow().__rtcConnections?.some(
|
||||
() => (window as unknown as WebRtcTestHarnessWindow).__rtcConnections?.some(
|
||||
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
||||
) ?? false
|
||||
);
|
||||
@@ -233,7 +230,7 @@ export async function isPeerStillConnected(page: Page): Promise<boolean> {
|
||||
/** Returns the number of tracked peer connections in `connected` state. */
|
||||
export async function getConnectedPeerCount(page: Page): Promise<number> {
|
||||
return page.evaluate(
|
||||
() => (webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
||||
() => ((window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
||||
(pc) => pc.connectionState === 'connected'
|
||||
).length ?? 0
|
||||
);
|
||||
@@ -241,19 +238,36 @@ export async function getConnectedPeerCount(page: Page): Promise<number> {
|
||||
|
||||
/** Wait until the expected number of peer connections are `connected`. */
|
||||
export async function waitForConnectedPeerCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
(count) => (webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
||||
(pc) => pc.connectionState === 'connected'
|
||||
).length === count,
|
||||
expectedCount,
|
||||
{ timeout }
|
||||
);
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
(count) => ((window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
||||
(pc) => pc.connectionState === 'connected'
|
||||
).length === count,
|
||||
expectedCount,
|
||||
{ timeout }
|
||||
);
|
||||
} catch (error) {
|
||||
const diagnostics = await page.evaluate(() => {
|
||||
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections ?? [];
|
||||
|
||||
return {
|
||||
connected: connections.filter((pc) => pc.connectionState === 'connected').length,
|
||||
states: connections.map((pc) => pc.connectionState)
|
||||
};
|
||||
});
|
||||
|
||||
throw new Error(
|
||||
`Expected ${expectedCount} connected peers within ${timeout}ms; `
|
||||
+ `saw ${diagnostics.connected} connected (${diagnostics.states.join(', ') || 'none'})`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the number of tracked RTCDataChannels in the open state. */
|
||||
export async function getOpenDataChannelCount(page: Page): Promise<number> {
|
||||
return page.evaluate(
|
||||
() => (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
||||
() => ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
||||
(channel) => channel.readyState === 'open'
|
||||
).length ?? 0
|
||||
);
|
||||
@@ -262,7 +276,7 @@ export async function getOpenDataChannelCount(page: Page): Promise<number> {
|
||||
/** Wait until the expected number of tracked RTCDataChannels are open. */
|
||||
export async function waitForOpenDataChannelCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
(count) => (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
||||
(count) => ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
||||
(channel) => channel.readyState === 'open'
|
||||
).length === count,
|
||||
expectedCount,
|
||||
@@ -273,7 +287,7 @@ export async function waitForOpenDataChannelCount(page: Page, expectedCount: num
|
||||
/** Close every currently-open RTCDataChannel and return how many were closed. */
|
||||
export async function closeOpenDataChannels(page: Page): Promise<number> {
|
||||
return page.evaluate(() => {
|
||||
const channels = (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
||||
const channels = ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
||||
|
||||
let closed = 0;
|
||||
|
||||
@@ -293,7 +307,7 @@ export async function closeOpenDataChannels(page: Page): Promise<number> {
|
||||
/** Dispatch a synthetic data-channel error event on each open channel. */
|
||||
export async function dispatchDataChannelErrors(page: Page): Promise<number> {
|
||||
return page.evaluate(() => {
|
||||
const channels = (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
||||
const channels = ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
||||
|
||||
let dispatched = 0;
|
||||
|
||||
@@ -354,7 +368,7 @@ interface PerPeerAudioStat {
|
||||
/** Get per-peer audio stats for every tracked RTCPeerConnection. */
|
||||
export async function getPerPeerAudioStats(page: Page): Promise<PerPeerAudioStat[]> {
|
||||
return page.evaluate(async () => {
|
||||
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
|
||||
if (!connections?.length) {
|
||||
return [];
|
||||
@@ -472,7 +486,7 @@ export async function getAudioStats(page: Page): Promise<{
|
||||
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
||||
}> {
|
||||
return page.evaluate(async () => {
|
||||
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
|
||||
if (!connections?.length)
|
||||
return { outbound: null, inbound: null };
|
||||
@@ -486,8 +500,8 @@ export async function getAudioStats(page: Page): Promise<{
|
||||
hasInbound: boolean;
|
||||
};
|
||||
|
||||
const hwm: Record<number, HWMEntry> = webRtcHarnessWindow().__rtcStatsHWM =
|
||||
(webRtcHarnessWindow().__rtcStatsHWM as Record<number, HWMEntry> | undefined) ?? {};
|
||||
const hwm: Record<number, HWMEntry> = (window as unknown as WebRtcTestHarnessWindow).__rtcStatsHWM =
|
||||
((window as unknown as WebRtcTestHarnessWindow).__rtcStatsHWM as Record<number, HWMEntry> | undefined) ?? {};
|
||||
|
||||
for (let idx = 0; idx < connections.length; idx++) {
|
||||
let stats: RTCStatsReport;
|
||||
@@ -596,7 +610,7 @@ export async function getAudioStatsDelta(page: Page, durationMs = 3_000): Promis
|
||||
export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
async () => {
|
||||
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
|
||||
if (!connections?.length)
|
||||
return false;
|
||||
@@ -705,7 +719,7 @@ export async function getVideoStats(page: Page): Promise<{
|
||||
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
||||
}> {
|
||||
return page.evaluate(async () => {
|
||||
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
|
||||
if (!connections?.length)
|
||||
return { outbound: null, inbound: null };
|
||||
@@ -719,8 +733,8 @@ export async function getVideoStats(page: Page): Promise<{
|
||||
hasInbound: boolean;
|
||||
}
|
||||
|
||||
const hwm: Record<number, VHWM> = webRtcHarnessWindow().__rtcVideoStatsHWM =
|
||||
(webRtcHarnessWindow().__rtcVideoStatsHWM as Record<number, VHWM> | undefined) ?? {};
|
||||
const hwm: Record<number, VHWM> = (window as unknown as WebRtcTestHarnessWindow).__rtcVideoStatsHWM =
|
||||
((window as unknown as WebRtcTestHarnessWindow).__rtcVideoStatsHWM as Record<number, VHWM> | undefined) ?? {};
|
||||
|
||||
for (let idx = 0; idx < connections.length; idx++) {
|
||||
let stats: RTCStatsReport;
|
||||
@@ -804,7 +818,7 @@ export async function getVideoStats(page: Page): Promise<{
|
||||
export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
async () => {
|
||||
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
|
||||
if (!connections?.length)
|
||||
return false;
|
||||
@@ -972,7 +986,7 @@ export async function waitForInboundVideoFlow(
|
||||
*/
|
||||
export async function dumpRtcDiagnostics(page: Page): Promise<string> {
|
||||
return page.evaluate(async () => {
|
||||
const conns = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
const conns = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
|
||||
if (!conns?.length)
|
||||
return 'No connections tracked';
|
||||
|
||||
@@ -31,6 +31,14 @@ test.describe('Multi-device session', () => {
|
||||
expect(instanceA).not.toEqual(instanceB);
|
||||
});
|
||||
|
||||
await test.step('shows one self identity in the members panel on each device', async () => {
|
||||
for (const client of [scenario.clientA, scenario.clientB]) {
|
||||
await expect(
|
||||
membersSidePanel(client.page).getByText(scenario.credentials.displayName, { exact: true })
|
||||
).toHaveCount(1, { timeout: 20_000 });
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('syncs chat from device A to device B', async () => {
|
||||
await expectCrossDeviceMessage(scenario.messagesA, scenario.messagesB, messageAtoB);
|
||||
});
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
dumpRtcDiagnostics,
|
||||
getConnectedPeerCount,
|
||||
installWebRTCTracking,
|
||||
installAutoResumeAudioContext,
|
||||
waitForAllPeerAudioFlow,
|
||||
waitForAudioStatsPresent,
|
||||
waitForConnectedPeerCount,
|
||||
waitForPeerConnected
|
||||
} from '../../helpers/webrtc-helpers';
|
||||
import {
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||
import { waitForVoiceRosterCount } from '../../helpers/voice-roster';
|
||||
import { getMinimumConnectedPeerMeshCount, waitForConnectedRemotePeerMesh } from '../../helpers/signal-manager';
|
||||
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
||||
|
||||
// ── Signal endpoint identifiers ──────────────────────────────────────
|
||||
@@ -132,7 +134,8 @@ test.describe('Mixed signal-config voice', () => {
|
||||
|
||||
await installTestServerEndpoints(client.context, groupEndpoints);
|
||||
await installDeterministicVoiceSettings(client.page);
|
||||
await installWebRTCTracking(client.page);
|
||||
await installWebRTCTracking(client.context);
|
||||
await installAutoResumeAudioContext(client.page);
|
||||
|
||||
clients.push({ ...client, user });
|
||||
}
|
||||
@@ -300,8 +303,11 @@ test.describe('Mixed signal-config voice', () => {
|
||||
|
||||
for (const client of clients) {
|
||||
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
|
||||
await client.page.waitForTimeout(2_000);
|
||||
}
|
||||
|
||||
await clients[0].page.waitForTimeout(10_000);
|
||||
|
||||
for (const client of clients) {
|
||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||
}
|
||||
@@ -310,11 +316,11 @@ test.describe('Mixed signal-config voice', () => {
|
||||
// ── Audio mesh ──────────────────────────────────────────────
|
||||
await test.step('All users discover peers and audio flows pairwise', async () => {
|
||||
await Promise.all(clients.map((client) =>
|
||||
waitForPeerConnected(client.page, 45_000)
|
||||
waitForPeerConnected(client.page, 90_000)
|
||||
));
|
||||
|
||||
await Promise.all(clients.map((client) =>
|
||||
waitForConnectedPeerCount(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
||||
waitForConnectedRemotePeerMesh(client.page, EXPECTED_REMOTE_PEERS, 180_000)
|
||||
));
|
||||
|
||||
await Promise.all(clients.map((client) =>
|
||||
@@ -324,7 +330,7 @@ test.describe('Mixed signal-config voice', () => {
|
||||
await clients[0].page.waitForTimeout(5_000);
|
||||
|
||||
await Promise.all(clients.map((client) =>
|
||||
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
||||
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 300_000)
|
||||
));
|
||||
});
|
||||
|
||||
@@ -335,7 +341,6 @@ test.describe('Mixed signal-config voice', () => {
|
||||
|
||||
await openVoiceWorkspace(client.page);
|
||||
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
|
||||
await waitForVoiceWorkspaceUserCount(client.page, USER_COUNT);
|
||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||
}
|
||||
});
|
||||
@@ -372,18 +377,28 @@ test.describe('Mixed signal-config voice', () => {
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
for (const client of stayers) {
|
||||
await expect.poll(async () => await getConnectedPeerCount(client.page), {
|
||||
await expect.poll(async () => {
|
||||
const actual = await getConnectedPeerCount(client.page);
|
||||
const minimum = await getMinimumConnectedPeerMeshCount(client.page, EXPECTED_REMOTE_PEERS);
|
||||
|
||||
return actual >= minimum;
|
||||
}, {
|
||||
timeout: 10_000,
|
||||
intervals: [500, 1_000]
|
||||
}).toBe(EXPECTED_REMOTE_PEERS);
|
||||
}).toBe(true);
|
||||
}
|
||||
|
||||
// Check chatters still have voice peers even while viewing another room
|
||||
for (const chatter of chatters) {
|
||||
await expect.poll(async () => await getConnectedPeerCount(chatter.page), {
|
||||
await expect.poll(async () => {
|
||||
const actual = await getConnectedPeerCount(chatter.page);
|
||||
const minimum = await getMinimumConnectedPeerMeshCount(chatter.page, EXPECTED_REMOTE_PEERS);
|
||||
|
||||
return actual >= minimum;
|
||||
}, {
|
||||
timeout: 10_000,
|
||||
intervals: [500, 1_000]
|
||||
}).toBe(EXPECTED_REMOTE_PEERS);
|
||||
}).toBe(true);
|
||||
}
|
||||
|
||||
if (Date.now() < deadline) {
|
||||
@@ -749,63 +764,6 @@ async function waitForLocalVoiceChannelConnection(page: Page, channelName: strin
|
||||
|
||||
// ── Roster / state helpers ───────────────────────────────────────────
|
||||
|
||||
async function waitForVoiceWorkspaceUserCount(page: Page, expectedCount: number): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
(count) => {
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
const host = document.querySelector('app-voice-workspace');
|
||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const connectedUsers = (component['connectedVoiceUsers'] as (() => unknown[]) | undefined)?.() ?? [];
|
||||
|
||||
return connectedUsers.length === count;
|
||||
},
|
||||
expectedCount,
|
||||
{ timeout: 45_000 }
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForVoiceRosterCount(page: Page, channelName: string, expectedCount: number): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
({ expected, name }) => {
|
||||
interface ChannelShape { id: string; name: string; type: 'text' | 'voice' }
|
||||
interface RoomShape { channels?: ChannelShape[] }
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
const host = document.querySelector('app-rooms-side-panel');
|
||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
|
||||
const channelId = currentRoom?.channels?.find((ch) => ch.type === 'voice' && ch.name === name)?.id;
|
||||
|
||||
if (!channelId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => unknown[]) | undefined)?.(channelId) ?? [];
|
||||
|
||||
return roster.length === expected;
|
||||
},
|
||||
{ expected: expectedCount, name: channelName },
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForVoiceStateAcrossPages(
|
||||
clients: readonly TestClient[],
|
||||
displayName: string,
|
||||
|
||||
@@ -6,14 +6,21 @@ import {
|
||||
dumpRtcDiagnostics,
|
||||
getConnectedPeerCount,
|
||||
installWebRTCTracking,
|
||||
installAutoResumeAudioContext,
|
||||
waitForAllPeerAudioFlow,
|
||||
waitForAudioStatsPresent,
|
||||
waitForConnectedPeerCount,
|
||||
waitForPeerConnected
|
||||
} from '../../helpers/webrtc-helpers';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||
import { waitForVoiceRosterCount } from '../../helpers/voice-roster';
|
||||
import {
|
||||
getConnectedSignalManagerCount,
|
||||
getMinimumConnectedPeerMeshCount,
|
||||
waitForConnectedRemotePeerMesh,
|
||||
waitForConnectedSignalManagerCount
|
||||
} from '../../helpers/signal-manager';
|
||||
|
||||
const PRIMARY_SIGNAL_ID = 'e2e-test-server-a';
|
||||
const SECONDARY_SIGNAL_ID = 'e2e-test-server-b';
|
||||
@@ -116,8 +123,11 @@ test.describe('Dual-signal multi-user voice', () => {
|
||||
|
||||
for (const client of clients) {
|
||||
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
|
||||
await client.page.waitForTimeout(2_000);
|
||||
}
|
||||
|
||||
await clients[0].page.waitForTimeout(10_000);
|
||||
|
||||
for (const client of clients) {
|
||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||
}
|
||||
@@ -126,12 +136,12 @@ test.describe('Dual-signal multi-user voice', () => {
|
||||
await test.step('All users discover all peers and audio flows pairwise', async () => {
|
||||
// Wait for all clients to have at least one connected peer (fast)
|
||||
await Promise.all(clients.map((client) =>
|
||||
waitForPeerConnected(client.page, 45_000)
|
||||
waitForPeerConnected(client.page, 90_000)
|
||||
));
|
||||
|
||||
// Wait for all clients to have all 7 peers connected
|
||||
await Promise.all(clients.map((client) =>
|
||||
waitForConnectedPeerCount(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
||||
waitForConnectedRemotePeerMesh(client.page, EXPECTED_REMOTE_PEERS, 180_000)
|
||||
));
|
||||
|
||||
// Wait for audio stats to appear on all clients
|
||||
@@ -146,7 +156,7 @@ test.describe('Dual-signal multi-user voice', () => {
|
||||
|
||||
// Check bidirectional audio flow on each client
|
||||
await Promise.all(clients.map((client) =>
|
||||
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
||||
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 300_000)
|
||||
));
|
||||
});
|
||||
|
||||
@@ -156,7 +166,6 @@ test.describe('Dual-signal multi-user voice', () => {
|
||||
|
||||
await openVoiceWorkspace(client.page);
|
||||
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
|
||||
await waitForVoiceWorkspaceUserCount(client.page, USER_COUNT);
|
||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||
await waitForConnectedSignalManagerCount(client.page, 2);
|
||||
}
|
||||
@@ -167,10 +176,15 @@ test.describe('Dual-signal multi-user voice', () => {
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
for (const client of clients) {
|
||||
await expect.poll(async () => await getConnectedPeerCount(client.page), {
|
||||
await expect.poll(async () => {
|
||||
const actual = await getConnectedPeerCount(client.page);
|
||||
const minimum = await getMinimumConnectedPeerMeshCount(client.page, EXPECTED_REMOTE_PEERS);
|
||||
|
||||
return actual >= minimum;
|
||||
}, {
|
||||
timeout: 10_000,
|
||||
intervals: [500, 1_000]
|
||||
}).toBe(EXPECTED_REMOTE_PEERS);
|
||||
}).toBe(true);
|
||||
|
||||
await expect.poll(async () => await getConnectedSignalManagerCount(client.page), {
|
||||
timeout: 10_000,
|
||||
@@ -292,7 +306,8 @@ async function createTrackedClients(
|
||||
|
||||
await installTestServerEndpoints(client.context, endpoints);
|
||||
await installDeterministicVoiceSettings(client.page);
|
||||
await installWebRTCTracking(client.page);
|
||||
await installWebRTCTracking(client.context);
|
||||
await installAutoResumeAudioContext(client.page);
|
||||
|
||||
clients.push({
|
||||
...client,
|
||||
@@ -576,124 +591,6 @@ async function getVoiceJoinDiagnostics(page: Page, channelName: string): Promise
|
||||
}, channelName);
|
||||
}
|
||||
|
||||
async function waitForConnectedSignalManagerCount(page: Page, expectedCount: number): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
(count) => {
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
const host = document.querySelector('app-rooms-side-panel');
|
||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const realtime = component['realtime'] as {
|
||||
signalingTransportHandler?: {
|
||||
getConnectedSignalingManagers?: () => { signalUrl: string }[];
|
||||
};
|
||||
} | undefined;
|
||||
const countValue = realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0;
|
||||
|
||||
return countValue === count;
|
||||
},
|
||||
expectedCount,
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
}
|
||||
|
||||
async function getConnectedSignalManagerCount(page: Page): Promise<number> {
|
||||
return await page.evaluate(() => {
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
const host = document.querySelector('app-rooms-side-panel');
|
||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const realtime = component['realtime'] as {
|
||||
signalingTransportHandler?: {
|
||||
getConnectedSignalingManagers?: () => { signalUrl: string }[];
|
||||
};
|
||||
} | undefined;
|
||||
|
||||
return realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0;
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForVoiceWorkspaceUserCount(page: Page, expectedCount: number): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
(count) => {
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
const host = document.querySelector('app-voice-workspace');
|
||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const connectedUsers = (component['connectedVoiceUsers'] as (() => unknown[]) | undefined)?.() ?? [];
|
||||
|
||||
return connectedUsers.length === count;
|
||||
},
|
||||
expectedCount,
|
||||
{ timeout: 45_000 }
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForVoiceRosterCount(page: Page, channelName: string, expectedCount: number): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
({ expected, name }) => {
|
||||
interface ChannelShape {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'text' | 'voice';
|
||||
}
|
||||
|
||||
interface RoomShape {
|
||||
channels?: ChannelShape[];
|
||||
}
|
||||
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
const host = document.querySelector('app-rooms-side-panel');
|
||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
|
||||
const channelId = currentRoom?.channels?.find((channel) => channel.type === 'voice' && channel.name === name)?.id;
|
||||
|
||||
if (!channelId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => unknown[]) | undefined)?.(channelId) ?? [];
|
||||
|
||||
return roster.length === expected;
|
||||
},
|
||||
{ expected: expectedCount, name: channelName },
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForVoiceStateAcrossPages(
|
||||
clients: readonly TestClient[],
|
||||
displayName: string,
|
||||
|
||||
127
e2e/tests/voice/voice-mute-state-reset.spec.ts
Normal file
127
e2e/tests/voice/voice-mute-state-reset.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { test, expect } from '../../fixtures/multi-client';
|
||||
import {
|
||||
MULTI_DEVICE_PASSWORD,
|
||||
MULTI_DEVICE_VOICE_CHANNEL,
|
||||
closeClient,
|
||||
loginSecondDeviceIntoServer,
|
||||
uniqueMultiDeviceName
|
||||
} from '../../helpers/multi-device-session';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||
|
||||
async function waitForVoiceMuteState(
|
||||
page: import('@playwright/test').Page,
|
||||
displayName: string,
|
||||
expectedMuted: boolean,
|
||||
timeout = 45_000
|
||||
): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
({ expectedDisplayName, expectedMuted: muted }) => {
|
||||
interface VoiceStateShape { isMuted?: boolean }
|
||||
interface UserShape { displayName: string; voiceState?: VoiceStateShape }
|
||||
interface ChannelShape { id: string; type: 'text' | 'voice' }
|
||||
interface RoomShape { channels?: ChannelShape[] }
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
const host = document.querySelector('app-rooms-side-panel');
|
||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
|
||||
const voiceChannel = currentRoom?.channels?.find((channel) => channel.type === 'voice');
|
||||
|
||||
if (!voiceChannel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => UserShape[]) | undefined)?.(voiceChannel.id) ?? [];
|
||||
const entry = roster.find((userEntry) => userEntry.displayName === expectedDisplayName);
|
||||
|
||||
return entry?.voiceState?.isMuted === muted;
|
||||
},
|
||||
{ expectedDisplayName: displayName, expectedMuted },
|
||||
{ timeout }
|
||||
);
|
||||
}
|
||||
|
||||
test.describe('Voice mute state reset', () => {
|
||||
test.describe.configure({ timeout: 300_000, retries: 1 });
|
||||
|
||||
test('clears stale mute state after abrupt disconnect and voice rejoin', async ({ createClient }) => {
|
||||
const suffix = uniqueMultiDeviceName('voice-mute-reset');
|
||||
const hostCredentials = {
|
||||
username: `host_${suffix}`,
|
||||
displayName: 'Voice Host',
|
||||
password: MULTI_DEVICE_PASSWORD
|
||||
};
|
||||
const guestCredentials = {
|
||||
username: `guest_${suffix}`,
|
||||
displayName: 'Voice Guest',
|
||||
password: MULTI_DEVICE_PASSWORD
|
||||
};
|
||||
const serverName = `Voice Mute Reset ${suffix}`;
|
||||
|
||||
let hostClient = await createClient();
|
||||
|
||||
const guestClient = await createClient();
|
||||
|
||||
await test.step('host creates the shared server', async () => {
|
||||
const registerPage = new RegisterPage(hostClient.page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(hostCredentials.username, hostCredentials.displayName, hostCredentials.password);
|
||||
await expect(hostClient.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
|
||||
const search = new ServerSearchPage(hostClient.page);
|
||||
|
||||
await search.createServer(serverName, { description: 'Voice mute reset coverage' });
|
||||
await expect(hostClient.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
const hostRoom = new ChatRoomPage(hostClient.page);
|
||||
|
||||
await hostRoom.ensureVoiceChannelExists(MULTI_DEVICE_VOICE_CHANNEL);
|
||||
|
||||
await test.step('guest joins the server', async () => {
|
||||
const registerPage = new RegisterPage(guestClient.page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(guestCredentials.username, guestCredentials.displayName, guestCredentials.password);
|
||||
await expect(guestClient.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
|
||||
const search = new ServerSearchPage(guestClient.page);
|
||||
|
||||
await search.joinServerFromSearch(serverName);
|
||||
await expect(guestClient.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('host joins voice muted and guest observes the muted state', async () => {
|
||||
await hostRoom.joinVoiceChannel(MULTI_DEVICE_VOICE_CHANNEL);
|
||||
await expect(hostRoom.voiceControls).toBeVisible({ timeout: 20_000 });
|
||||
await hostRoom.muteButton.click();
|
||||
|
||||
await waitForVoiceMuteState(guestClient.page, hostCredentials.displayName, true);
|
||||
});
|
||||
|
||||
await test.step('abrupt host disconnect clears stale mute before rejoin', async () => {
|
||||
await closeClient(hostClient);
|
||||
|
||||
hostClient = await createClient();
|
||||
await loginSecondDeviceIntoServer(hostClient.page, hostCredentials, serverName);
|
||||
|
||||
const reopenedRoom = new ChatRoomPage(hostClient.page);
|
||||
|
||||
await reopenedRoom.joinVoiceChannel(MULTI_DEVICE_VOICE_CHANNEL);
|
||||
await expect(reopenedRoom.voiceControls).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await waitForVoiceMuteState(guestClient.page, hostCredentials.displayName, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,10 @@ export interface AppMetricsProcessSnapshot {
|
||||
pid: number;
|
||||
type: string;
|
||||
workingSetKb: number | null;
|
||||
peakWorkingSetKb: number | null;
|
||||
privateBytesKb: number | null;
|
||||
creationTime: number | null;
|
||||
cpuPercent: number | null;
|
||||
}
|
||||
|
||||
export interface AppMetricsSnapshot {
|
||||
@@ -17,7 +21,17 @@ export function collectAppMetricsSnapshot(): AppMetricsSnapshot {
|
||||
processes: app.getAppMetrics().map((metric) => ({
|
||||
pid: metric.pid,
|
||||
type: metric.type,
|
||||
workingSetKb: metric.memory?.workingSetSize ?? null
|
||||
workingSetKb: metric.memory?.workingSetSize ?? null,
|
||||
peakWorkingSetKb: readOptionalKilobytes(metric.memory?.peakWorkingSetSize),
|
||||
privateBytesKb: readOptionalKilobytes(metric.memory?.privateBytes),
|
||||
creationTime: metric.creationTime ?? null,
|
||||
cpuPercent: typeof metric.cpu?.percentCPUUsage === 'number'
|
||||
? Math.round(metric.cpu.percentCPUUsage * 10) / 10
|
||||
: null
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function readOptionalKilobytes(value: number | undefined): number | null {
|
||||
return typeof value === 'number' && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { isPerfDiagEnabled } from './diagnostics.flags';
|
||||
describe('isPerfDiagEnabled', () => {
|
||||
it('returns false when the flag is unset', () => {
|
||||
expect(isPerfDiagEnabled({}, false)).toBe(false);
|
||||
expect(isPerfDiagEnabled({}, true)).toBe(false);
|
||||
expect(isPerfDiagEnabled({}, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true in development when METOYOU_PERF_DIAG is truthy', () => {
|
||||
@@ -17,11 +17,12 @@ describe('isPerfDiagEnabled', () => {
|
||||
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: 'on' }, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false in packaged builds unless force is set', () => {
|
||||
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '1' }, true)).toBe(false);
|
||||
expect(isPerfDiagEnabled({
|
||||
METOYOU_PERF_DIAG: '1',
|
||||
METOYOU_PERF_DIAG_FORCE: '1'
|
||||
}, true)).toBe(true);
|
||||
it('returns true in packaged Electron builds without env flags', () => {
|
||||
expect(isPerfDiagEnabled({}, true)).toBe(true);
|
||||
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '0' }, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false in development when the flag is unset', () => {
|
||||
expect(isPerfDiagEnabled({}, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,13 +17,9 @@ export function isPerfDiagEnabled(
|
||||
env: NodeJS.ProcessEnv,
|
||||
isPackaged: boolean
|
||||
): boolean {
|
||||
if (!isTruthyFlag(env[PERF_DIAG_ENV])) {
|
||||
return false;
|
||||
if (isPackaged) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isPackaged && !isTruthyFlag(env[PERF_DIAG_FORCE_ENV])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return isTruthyFlag(env[PERF_DIAG_ENV]);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
ipcMain
|
||||
ipcMain,
|
||||
shell
|
||||
} from 'electron';
|
||||
import { collectAppMetricsSnapshot } from '../app-metrics';
|
||||
import { collectAppMetricsSnapshot, type AppMetricsSnapshot } from '../app-metrics';
|
||||
import { getMainWindow } from '../window/create-window';
|
||||
import { resolveReadablePath } from '../path-jail';
|
||||
import { sumWorkingSetKb } from './process-metrics.rules';
|
||||
import { isPerfDiagEnabled } from './diagnostics.flags';
|
||||
import { exceedsHighMemoryThreshold } from './high-memory-alert.rules';
|
||||
import { buildHighMemoryDiagnosticPayload } from './high-memory-snapshot.rules';
|
||||
import { collectImmediateRendererSamples } from './immediate-renderer-samples.collector';
|
||||
import { collectSessionContext } from './session-context.collector';
|
||||
import {
|
||||
clearHighMemoryAlert,
|
||||
readHighMemoryAlert,
|
||||
writeHighMemoryAlert
|
||||
} from './high-memory-alert.store';
|
||||
import type { PerfDiagEntry } from './diagnostics.models';
|
||||
import { PerfDiagWriter } from './diagnostics.writer';
|
||||
|
||||
@@ -15,6 +27,8 @@ let activeWriter: PerfDiagWriter | null = null;
|
||||
let processPollTimer: NodeJS.Timeout | null = null;
|
||||
let diagnosticsEnabled = false;
|
||||
let ipcRegistered = false;
|
||||
let highMemoryAlertTriggeredThisSession = false;
|
||||
let sessionStartedAt = 0;
|
||||
|
||||
export function isPerfDiagActive(): boolean {
|
||||
return diagnosticsEnabled;
|
||||
@@ -43,6 +57,37 @@ export function ensurePerfDiagIpcRegistered(): void {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-pending-high-memory-alert', async () => {
|
||||
return readHighMemoryAlert(app.getPath('userData'));
|
||||
});
|
||||
|
||||
ipcMain.handle('acknowledge-high-memory-alert', async () => {
|
||||
await clearHighMemoryAlert(app.getPath('userData'));
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('show-log-file-in-folder', async (_event, filePath: string) => {
|
||||
if (typeof filePath !== 'string' || !filePath.trim()) {
|
||||
return {
|
||||
shown: false,
|
||||
reason: 'missing-path'
|
||||
};
|
||||
}
|
||||
|
||||
const scopedPath = await resolveReadablePath(filePath);
|
||||
|
||||
if (!scopedPath) {
|
||||
return {
|
||||
shown: false,
|
||||
reason: 'outside-app-data'
|
||||
};
|
||||
}
|
||||
|
||||
shell.showItemInFolder(scopedPath);
|
||||
|
||||
return { shown: true };
|
||||
});
|
||||
}
|
||||
|
||||
export function getActivePerfDiagWriter(): PerfDiagWriter | null {
|
||||
@@ -64,9 +109,13 @@ export function startPerfDiagnostics(): PerfDiagWriter | null {
|
||||
});
|
||||
|
||||
activeWriter = writer;
|
||||
highMemoryAlertTriggeredThisSession = false;
|
||||
sessionStartedAt = Date.now();
|
||||
registerProcessCrashHandlers(writer);
|
||||
startProcessMetricsPolling(writer);
|
||||
|
||||
const userDataPath = app.getPath('userData');
|
||||
|
||||
writer.append({
|
||||
collectedAt: Date.now(),
|
||||
source: 'main',
|
||||
@@ -78,6 +127,18 @@ export function startPerfDiagnostics(): PerfDiagWriter | null {
|
||||
}
|
||||
});
|
||||
|
||||
writer.append({
|
||||
collectedAt: Date.now(),
|
||||
source: 'main',
|
||||
type: 'environment',
|
||||
payload: {
|
||||
...collectSessionContext({
|
||||
sessionStartedAt,
|
||||
userDataPath
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
return writer;
|
||||
}
|
||||
|
||||
@@ -195,6 +256,8 @@ function startProcessMetricsPolling(writer: PerfDiagWriter): void {
|
||||
processes: metrics.processes
|
||||
}
|
||||
});
|
||||
|
||||
void maybeTriggerHighMemoryAlert(writer, metrics, totalKb);
|
||||
} catch {
|
||||
// Collector failures must never affect the app.
|
||||
}
|
||||
@@ -204,6 +267,64 @@ function startProcessMetricsPolling(writer: PerfDiagWriter): void {
|
||||
processPollTimer = setInterval(sample, PROCESS_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
async function maybeTriggerHighMemoryAlert(
|
||||
writer: PerfDiagWriter,
|
||||
metrics: AppMetricsSnapshot,
|
||||
totalWorkingSetKb: number | null
|
||||
): Promise<void> {
|
||||
if (highMemoryAlertTriggeredThisSession || !exceedsHighMemoryThreshold(totalWorkingSetKb)) {
|
||||
return;
|
||||
}
|
||||
|
||||
highMemoryAlertTriggeredThisSession = true;
|
||||
|
||||
const detectedAt = Date.now();
|
||||
const userDataPath = app.getPath('userData');
|
||||
const immediateRendererEntries = await collectImmediateRendererSamples(getMainWindow());
|
||||
const environment = collectSessionContext({
|
||||
sessionStartedAt,
|
||||
userDataPath
|
||||
});
|
||||
|
||||
for (const entry of immediateRendererEntries) {
|
||||
writer.append(entry);
|
||||
}
|
||||
|
||||
writer.append({
|
||||
collectedAt: detectedAt,
|
||||
source: 'main',
|
||||
type: 'environment',
|
||||
payload: {
|
||||
...environment
|
||||
}
|
||||
});
|
||||
|
||||
writer.append({
|
||||
collectedAt: detectedAt,
|
||||
source: 'main',
|
||||
type: 'high-memory',
|
||||
payload: buildHighMemoryDiagnosticPayload({
|
||||
detectedAt,
|
||||
totalWorkingSetKb: totalWorkingSetKb ?? 0,
|
||||
metrics,
|
||||
environment,
|
||||
mainProcessMemory: process.memoryUsage(),
|
||||
ringEntries: writer.bufferedEntries,
|
||||
immediateRendererEntries,
|
||||
sessionId: writer.sessionId
|
||||
})
|
||||
});
|
||||
|
||||
await writer.flushSnapshot('high-memory-threshold');
|
||||
|
||||
await writeHighMemoryAlert(userDataPath, {
|
||||
logFilePath: writer.snapshotFilePath,
|
||||
detectedAt,
|
||||
peakWorkingSetKb: totalWorkingSetKb ?? 0,
|
||||
sessionId: writer.sessionId
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRendererEntry(entry: PerfDiagEntry): PerfDiagEntry {
|
||||
return {
|
||||
collectedAt: Number(entry.collectedAt) || Date.now(),
|
||||
|
||||
@@ -2,10 +2,12 @@ export type PerfDiagSource = 'main' | 'renderer';
|
||||
|
||||
export type PerfDiagEntryType =
|
||||
| 'session'
|
||||
| 'environment'
|
||||
| 'process'
|
||||
| 'store'
|
||||
| 'components'
|
||||
| 'heap'
|
||||
| 'high-memory'
|
||||
| 'crash'
|
||||
| 'unresponsive';
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
resolveDiagnosticsFilePath
|
||||
} from './diagnostics.rules';
|
||||
|
||||
const DEFAULT_RING_CAPACITY = 120;
|
||||
const DEFAULT_RING_CAPACITY = 300;
|
||||
const FLUSH_DEBOUNCE_MS = 250;
|
||||
|
||||
export interface PerfDiagWriterOptions {
|
||||
@@ -18,6 +18,7 @@ export interface PerfDiagWriterOptions {
|
||||
|
||||
export class PerfDiagWriter {
|
||||
private readonly filePath: string;
|
||||
private readonly sessionIdValue: string;
|
||||
private readonly ringCapacity: number;
|
||||
private readonly pendingLines: string[] = [];
|
||||
private ring: PerfDiagEntry[] = [];
|
||||
@@ -26,10 +27,15 @@ export class PerfDiagWriter {
|
||||
private disabled = false;
|
||||
|
||||
constructor(options: PerfDiagWriterOptions) {
|
||||
this.sessionIdValue = options.sessionId;
|
||||
this.filePath = resolveDiagnosticsFilePath(options.userDataPath, options.sessionId);
|
||||
this.ringCapacity = options.ringCapacity ?? DEFAULT_RING_CAPACITY;
|
||||
}
|
||||
|
||||
get sessionId(): string {
|
||||
return this.sessionIdValue;
|
||||
}
|
||||
|
||||
get snapshotFilePath(): string {
|
||||
return this.filePath;
|
||||
}
|
||||
|
||||
27
electron/diagnostics/high-memory-alert.rules.spec.ts
Normal file
27
electron/diagnostics/high-memory-alert.rules.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import {
|
||||
exceedsHighMemoryThreshold,
|
||||
formatWorkingSetGb,
|
||||
HIGH_MEMORY_THRESHOLD_KB
|
||||
} from './high-memory-alert.rules';
|
||||
|
||||
describe('high-memory-alert.rules', () => {
|
||||
it('uses a 2 GiB working-set threshold', () => {
|
||||
expect(HIGH_MEMORY_THRESHOLD_KB).toBe(2 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it('detects totals at or above the threshold', () => {
|
||||
expect(exceedsHighMemoryThreshold(HIGH_MEMORY_THRESHOLD_KB - 1)).toBe(false);
|
||||
expect(exceedsHighMemoryThreshold(HIGH_MEMORY_THRESHOLD_KB)).toBe(true);
|
||||
expect(exceedsHighMemoryThreshold(HIGH_MEMORY_THRESHOLD_KB + 1024)).toBe(true);
|
||||
});
|
||||
|
||||
it('formats working set totals in gigabytes', () => {
|
||||
expect(formatWorkingSetGb(1536 * 1024)).toBe('1.50');
|
||||
expect(formatWorkingSetGb(HIGH_MEMORY_THRESHOLD_KB)).toBe('2.00');
|
||||
});
|
||||
});
|
||||
11
electron/diagnostics/high-memory-alert.rules.ts
Normal file
11
electron/diagnostics/high-memory-alert.rules.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/** 2 GiB working-set threshold for writing a diagnostics snapshot. */
|
||||
export const HIGH_MEMORY_THRESHOLD_KB = 2 * 1024 * 1024;
|
||||
|
||||
export function exceedsHighMemoryThreshold(totalWorkingSetKb: number | null | undefined): boolean {
|
||||
return typeof totalWorkingSetKb === 'number'
|
||||
&& totalWorkingSetKb >= HIGH_MEMORY_THRESHOLD_KB;
|
||||
}
|
||||
|
||||
export function formatWorkingSetGb(totalWorkingSetKb: number): string {
|
||||
return (totalWorkingSetKb / (1024 * 1024)).toFixed(2);
|
||||
}
|
||||
64
electron/diagnostics/high-memory-alert.store.spec.ts
Normal file
64
electron/diagnostics/high-memory-alert.store.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import {
|
||||
clearHighMemoryAlert,
|
||||
readHighMemoryAlert,
|
||||
resolveHighMemoryAlertPath,
|
||||
writeHighMemoryAlert
|
||||
} from './high-memory-alert.store';
|
||||
|
||||
describe('high-memory-alert.store', () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fsp.rm(dir, {
|
||||
recursive: true,
|
||||
force: true
|
||||
})));
|
||||
});
|
||||
|
||||
it('writes and reads a pending startup alert record', async () => {
|
||||
const userDataPath = await fsp.mkdtemp(path.join(os.tmpdir(), 'metoyou-high-memory-'));
|
||||
|
||||
tempDirs.push(userDataPath);
|
||||
|
||||
const record = {
|
||||
logFilePath: path.join(userDataPath, 'diagnostics', 'perf-session.jsonl'),
|
||||
detectedAt: 1_700_000_000_000,
|
||||
peakWorkingSetKb: 2_200_000,
|
||||
sessionId: 'session-1'
|
||||
};
|
||||
|
||||
await writeHighMemoryAlert(userDataPath, record);
|
||||
|
||||
expect(resolveHighMemoryAlertPath(userDataPath)).toBe(
|
||||
path.join(userDataPath, 'diagnostics', 'high-memory-pending.json')
|
||||
);
|
||||
|
||||
expect(await readHighMemoryAlert(userDataPath)).toEqual(record);
|
||||
});
|
||||
|
||||
it('clears the pending startup alert record', async () => {
|
||||
const userDataPath = await fsp.mkdtemp(path.join(os.tmpdir(), 'metoyou-high-memory-'));
|
||||
|
||||
tempDirs.push(userDataPath);
|
||||
|
||||
await writeHighMemoryAlert(userDataPath, {
|
||||
logFilePath: '/tmp/perf.jsonl',
|
||||
detectedAt: Date.now(),
|
||||
peakWorkingSetKb: 2_100_000,
|
||||
sessionId: 'session-2'
|
||||
});
|
||||
|
||||
await clearHighMemoryAlert(userDataPath);
|
||||
|
||||
expect(await readHighMemoryAlert(userDataPath)).toBeNull();
|
||||
});
|
||||
});
|
||||
57
electron/diagnostics/high-memory-alert.store.ts
Normal file
57
electron/diagnostics/high-memory-alert.store.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface HighMemoryAlertRecord {
|
||||
logFilePath: string;
|
||||
detectedAt: number;
|
||||
peakWorkingSetKb: number;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export function resolveHighMemoryAlertPath(userDataPath: string): string {
|
||||
return path.join(userDataPath, 'diagnostics', 'high-memory-pending.json');
|
||||
}
|
||||
|
||||
export async function readHighMemoryAlert(userDataPath: string): Promise<HighMemoryAlertRecord | null> {
|
||||
try {
|
||||
const raw = await fsp.readFile(resolveHighMemoryAlertPath(userDataPath), 'utf8');
|
||||
const parsed = JSON.parse(raw) as Partial<HighMemoryAlertRecord>;
|
||||
|
||||
if (
|
||||
typeof parsed.logFilePath !== 'string'
|
||||
|| !parsed.logFilePath.trim()
|
||||
|| typeof parsed.detectedAt !== 'number'
|
||||
|| typeof parsed.peakWorkingSetKb !== 'number'
|
||||
|| typeof parsed.sessionId !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
logFilePath: parsed.logFilePath,
|
||||
detectedAt: parsed.detectedAt,
|
||||
peakWorkingSetKb: parsed.peakWorkingSetKb,
|
||||
sessionId: parsed.sessionId
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeHighMemoryAlert(
|
||||
userDataPath: string,
|
||||
record: HighMemoryAlertRecord
|
||||
): Promise<void> {
|
||||
const filePath = resolveHighMemoryAlertPath(userDataPath);
|
||||
|
||||
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fsp.writeFile(filePath, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
export async function clearHighMemoryAlert(userDataPath: string): Promise<void> {
|
||||
try {
|
||||
await fsp.unlink(resolveHighMemoryAlertPath(userDataPath));
|
||||
} catch {
|
||||
// Missing pending alert is fine.
|
||||
}
|
||||
}
|
||||
201
electron/diagnostics/high-memory-snapshot.rules.spec.ts
Normal file
201
electron/diagnostics/high-memory-snapshot.rules.spec.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import type { PerfDiagEntry } from './diagnostics.models';
|
||||
import {
|
||||
buildHighMemoryDiagnosticPayload,
|
||||
buildHighMemorySummary,
|
||||
extractLatestRendererSamples,
|
||||
extractProcessHistory,
|
||||
formatMemoryUsageMb,
|
||||
rankProcessesByWorkingSet,
|
||||
summarizeRingBuffer
|
||||
} from './high-memory-snapshot.rules';
|
||||
|
||||
function createProcess(overrides: Partial<{
|
||||
pid: number;
|
||||
type: string;
|
||||
workingSetKb: number | null;
|
||||
peakWorkingSetKb: number | null;
|
||||
privateBytesKb: number | null;
|
||||
creationTime: number | null;
|
||||
cpuPercent: number | null;
|
||||
}> = {}) {
|
||||
return {
|
||||
pid: 1,
|
||||
type: 'Tab',
|
||||
workingSetKb: 1024,
|
||||
peakWorkingSetKb: null,
|
||||
privateBytesKb: null,
|
||||
creationTime: null,
|
||||
cpuPercent: null,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('high-memory-snapshot.rules', () => {
|
||||
it('ranks processes by working set and computes share percentages', () => {
|
||||
const tabProcess = createProcess({ pid: 1, type: 'Tab', workingSetKb: 512_000 });
|
||||
const gpuProcess = createProcess({ pid: 2, type: 'GPU', workingSetKb: 1_536_000 });
|
||||
const ranked = rankProcessesByWorkingSet([tabProcess, gpuProcess], 2_048_000);
|
||||
|
||||
expect(ranked[0]?.type).toBe('GPU');
|
||||
expect(ranked[0]?.sharePercent).toBe(75);
|
||||
expect(ranked[1]?.sharePercent).toBe(25);
|
||||
});
|
||||
|
||||
it('extracts the latest renderer store, heap, and component samples', () => {
|
||||
const entries: PerfDiagEntry[] = [
|
||||
{
|
||||
collectedAt: 1,
|
||||
source: 'renderer',
|
||||
type: 'store',
|
||||
payload: { domains: { chat: 100 } }
|
||||
},
|
||||
{
|
||||
collectedAt: 2,
|
||||
source: 'renderer',
|
||||
type: 'heap',
|
||||
payload: { usedJsHeapMb: 120 }
|
||||
},
|
||||
{
|
||||
collectedAt: 3,
|
||||
source: 'renderer',
|
||||
type: 'components',
|
||||
payload: { suspectedLeaks: [{ name: 'ChatMessageItem', count: 40, expected: 20 }] }
|
||||
},
|
||||
{
|
||||
collectedAt: 4,
|
||||
source: 'renderer',
|
||||
type: 'store',
|
||||
payload: { domains: { chat: 500 } }
|
||||
}
|
||||
];
|
||||
|
||||
expect(extractLatestRendererSamples(entries)).toEqual({
|
||||
store: { domains: { chat: 500 } },
|
||||
heap: { usedJsHeapMb: 120 },
|
||||
components: { suspectedLeaks: [{ name: 'ChatMessageItem', count: 40, expected: 20 }] }
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts recent process history from the ring buffer', () => {
|
||||
const entries: PerfDiagEntry[] = [
|
||||
{
|
||||
collectedAt: 1,
|
||||
source: 'main',
|
||||
type: 'process',
|
||||
payload: { totalWorkingSetKb: 1000 }
|
||||
},
|
||||
{
|
||||
collectedAt: 2,
|
||||
source: 'main',
|
||||
type: 'session',
|
||||
payload: { event: 'noop' }
|
||||
},
|
||||
{
|
||||
collectedAt: 3,
|
||||
source: 'main',
|
||||
type: 'process',
|
||||
payload: { totalWorkingSetKb: 2000 }
|
||||
}
|
||||
];
|
||||
|
||||
expect(extractProcessHistory(entries)).toEqual([{ collectedAt: 1, totalWorkingSetKb: 1000 }, { collectedAt: 3, totalWorkingSetKb: 2000 }]);
|
||||
});
|
||||
|
||||
it('summarizes ring buffer entry counts', () => {
|
||||
expect(summarizeRingBuffer([
|
||||
{ collectedAt: 1, source: 'main', type: 'process', payload: {} },
|
||||
{ collectedAt: 2, source: 'renderer', type: 'heap', payload: {} },
|
||||
{ collectedAt: 3, source: 'main', type: 'process', payload: {} }
|
||||
])).toEqual({
|
||||
'main:process': 2,
|
||||
'renderer:heap': 1
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a high-memory summary with threshold context', () => {
|
||||
const summary = buildHighMemorySummary(
|
||||
2_200_000,
|
||||
[createProcess({ workingSetKb: 2_200_000 })],
|
||||
1_700_000_000_000
|
||||
);
|
||||
|
||||
expect(summary.totalWorkingSetGb).toBe('2.10');
|
||||
expect(summary.thresholdGb).toBe('2.00');
|
||||
expect(summary.topProcesses).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('builds a comprehensive high-memory diagnostic payload', () => {
|
||||
const payload = buildHighMemoryDiagnosticPayload({
|
||||
detectedAt: 1_700_000_000_000,
|
||||
totalWorkingSetKb: 2_200_000,
|
||||
metrics: {
|
||||
collectedAt: 1_700_000_000_000,
|
||||
processes: [
|
||||
createProcess({
|
||||
workingSetKb: 2_200_000,
|
||||
peakWorkingSetKb: 2_300_000,
|
||||
privateBytesKb: 1_800_000,
|
||||
creationTime: 1,
|
||||
cpuPercent: 12
|
||||
})
|
||||
]
|
||||
},
|
||||
environment: { appVersion: '1.0.0' },
|
||||
mainProcessMemory: {
|
||||
rss: 64 * 1024 * 1024,
|
||||
heapTotal: 32 * 1024 * 1024,
|
||||
heapUsed: 16 * 1024 * 1024,
|
||||
external: 8 * 1024 * 1024,
|
||||
arrayBuffers: 1024
|
||||
},
|
||||
ringEntries: [
|
||||
{
|
||||
collectedAt: 1,
|
||||
source: 'main',
|
||||
type: 'process',
|
||||
payload: { totalWorkingSetKb: 2_000_000 }
|
||||
}
|
||||
],
|
||||
immediateRendererEntries: [
|
||||
{
|
||||
collectedAt: 2,
|
||||
source: 'renderer',
|
||||
type: 'heap',
|
||||
payload: { usedJsHeapMb: 300, route: '/room/abc' }
|
||||
}
|
||||
],
|
||||
sessionId: 'session-1'
|
||||
});
|
||||
|
||||
expect(payload.event).toBe('high-memory-threshold');
|
||||
expect(payload.summary).toMatchObject({
|
||||
totalWorkingSetKb: 2_200_000
|
||||
});
|
||||
|
||||
expect(payload.processHistory).toHaveLength(1);
|
||||
expect(payload.recentRendererSamples).toEqual({
|
||||
store: null,
|
||||
heap: { usedJsHeapMb: 300, route: '/room/abc' },
|
||||
components: null
|
||||
});
|
||||
|
||||
expect(formatMemoryUsageMb({
|
||||
rss: 64 * 1024 * 1024,
|
||||
heapTotal: 32 * 1024 * 1024,
|
||||
heapUsed: 16 * 1024 * 1024,
|
||||
external: 8 * 1024 * 1024,
|
||||
arrayBuffers: 1024
|
||||
})).toEqual({
|
||||
rssMb: 64,
|
||||
heapTotalMb: 32,
|
||||
heapUsedMb: 16,
|
||||
externalMb: 8,
|
||||
arrayBuffersMb: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
179
electron/diagnostics/high-memory-snapshot.rules.ts
Normal file
179
electron/diagnostics/high-memory-snapshot.rules.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import type { AppMetricsProcessSnapshot, AppMetricsSnapshot } from '../app-metrics';
|
||||
import type { PerfDiagEntry } from './diagnostics.models';
|
||||
import { formatWorkingSetGb, HIGH_MEMORY_THRESHOLD_KB } from './high-memory-alert.rules';
|
||||
import type { SessionContextSnapshot } from './session-context.collector';
|
||||
|
||||
export interface RankedProcessSnapshot extends AppMetricsProcessSnapshot {
|
||||
sharePercent: number;
|
||||
}
|
||||
|
||||
export interface HighMemorySummary {
|
||||
detectedAt: number;
|
||||
thresholdKb: number;
|
||||
thresholdGb: string;
|
||||
totalWorkingSetKb: number;
|
||||
totalWorkingSetGb: string;
|
||||
topProcesses: RankedProcessSnapshot[];
|
||||
}
|
||||
|
||||
export interface LatestRendererSamples {
|
||||
store: Record<string, unknown> | null;
|
||||
heap: Record<string, unknown> | null;
|
||||
components: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export function rankProcessesByWorkingSet(
|
||||
processes: readonly AppMetricsProcessSnapshot[],
|
||||
totalWorkingSetKb: number | null
|
||||
): RankedProcessSnapshot[] {
|
||||
const total = totalWorkingSetKb ?? 0;
|
||||
|
||||
return [...processes]
|
||||
.filter((process) => process.workingSetKb != null && process.workingSetKb > 0)
|
||||
.sort((left, right) => (right.workingSetKb ?? 0) - (left.workingSetKb ?? 0))
|
||||
.map((process) => ({
|
||||
...process,
|
||||
sharePercent: total > 0
|
||||
? Math.round(((process.workingSetKb ?? 0) / total) * 1000) / 10
|
||||
: 0
|
||||
}));
|
||||
}
|
||||
|
||||
export function extractLatestRendererSamples(entries: readonly PerfDiagEntry[]): LatestRendererSamples {
|
||||
let store: Record<string, unknown> | null = null;
|
||||
let heap: Record<string, unknown> | null = null;
|
||||
let components: Record<string, unknown> | null = null;
|
||||
|
||||
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||||
const entry = entries[index];
|
||||
|
||||
if (entry.source !== 'renderer') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!store && entry.type === 'store') {
|
||||
store = entry.payload;
|
||||
}
|
||||
|
||||
if (!heap && entry.type === 'heap') {
|
||||
heap = entry.payload;
|
||||
}
|
||||
|
||||
if (!components && entry.type === 'components') {
|
||||
components = entry.payload;
|
||||
}
|
||||
|
||||
if (store && heap && components) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
store,
|
||||
heap,
|
||||
components
|
||||
};
|
||||
}
|
||||
|
||||
export function extractProcessHistory(
|
||||
entries: readonly PerfDiagEntry[],
|
||||
limit = 24
|
||||
): Record<string, unknown>[] {
|
||||
const history: Record<string, unknown>[] = [];
|
||||
|
||||
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||||
const entry = entries[index];
|
||||
|
||||
if (entry.type !== 'process') {
|
||||
continue;
|
||||
}
|
||||
|
||||
history.unshift({
|
||||
collectedAt: entry.collectedAt,
|
||||
...entry.payload
|
||||
});
|
||||
|
||||
if (history.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
export function summarizeRingBuffer(entries: readonly PerfDiagEntry[]): Record<string, number> {
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
for (const entry of entries) {
|
||||
const key = `${entry.source}:${entry.type}`;
|
||||
|
||||
counts[key] = (counts[key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
export function buildHighMemorySummary(
|
||||
totalWorkingSetKb: number,
|
||||
processes: readonly AppMetricsProcessSnapshot[],
|
||||
detectedAt: number
|
||||
): HighMemorySummary {
|
||||
return {
|
||||
detectedAt,
|
||||
thresholdKb: HIGH_MEMORY_THRESHOLD_KB,
|
||||
thresholdGb: formatWorkingSetGb(HIGH_MEMORY_THRESHOLD_KB),
|
||||
totalWorkingSetKb,
|
||||
totalWorkingSetGb: formatWorkingSetGb(totalWorkingSetKb),
|
||||
topProcesses: rankProcessesByWorkingSet(processes, totalWorkingSetKb).slice(0, 12)
|
||||
};
|
||||
}
|
||||
|
||||
export function formatMemoryUsageMb(memoryUsage: NodeJS.MemoryUsage): Record<string, number> {
|
||||
return {
|
||||
rssMb: roundMb(memoryUsage.rss),
|
||||
heapTotalMb: roundMb(memoryUsage.heapTotal),
|
||||
heapUsedMb: roundMb(memoryUsage.heapUsed),
|
||||
externalMb: roundMb(memoryUsage.external),
|
||||
arrayBuffersMb: roundMb(memoryUsage.arrayBuffers ?? 0)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildHighMemoryDiagnosticPayload(input: {
|
||||
detectedAt: number;
|
||||
totalWorkingSetKb: number;
|
||||
metrics: AppMetricsSnapshot;
|
||||
environment: SessionContextSnapshot;
|
||||
mainProcessMemory: NodeJS.MemoryUsage;
|
||||
ringEntries: readonly PerfDiagEntry[];
|
||||
immediateRendererEntries: readonly PerfDiagEntry[];
|
||||
sessionId: string;
|
||||
}): Record<string, unknown> {
|
||||
const mergedRingEntries = [...input.ringEntries, ...input.immediateRendererEntries];
|
||||
const recentRendererSamples = extractLatestRendererSamples(mergedRingEntries);
|
||||
|
||||
return {
|
||||
event: 'high-memory-threshold',
|
||||
sessionId: input.sessionId,
|
||||
summary: buildHighMemorySummary(
|
||||
input.totalWorkingSetKb,
|
||||
input.metrics.processes,
|
||||
input.detectedAt
|
||||
),
|
||||
environment: input.environment,
|
||||
metrics: input.metrics,
|
||||
mainProcessMemory: input.mainProcessMemory,
|
||||
mainProcessMemoryMb: formatMemoryUsageMb(input.mainProcessMemory),
|
||||
processHistory: extractProcessHistory(mergedRingEntries),
|
||||
ringSummary: summarizeRingBuffer(mergedRingEntries),
|
||||
recentRendererSamples,
|
||||
immediateRendererSamples: input.immediateRendererEntries.map((entry) => ({
|
||||
collectedAt: entry.collectedAt,
|
||||
type: entry.type,
|
||||
payload: entry.payload
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function roundMb(bytes: number): number {
|
||||
return Math.round((bytes / (1024 * 1024)) * 100) / 100;
|
||||
}
|
||||
39
electron/diagnostics/immediate-renderer-samples.collector.ts
Normal file
39
electron/diagnostics/immediate-renderer-samples.collector.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { PerfDiagEntry } from './diagnostics.models';
|
||||
|
||||
export async function collectImmediateRendererSamples(
|
||||
window: BrowserWindow | null | undefined
|
||||
): Promise<PerfDiagEntry[]> {
|
||||
if (!window || window.isDestroyed()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.webContents.executeJavaScript(`
|
||||
(function () {
|
||||
const collect = globalThis.__collectPerfDiagSample;
|
||||
|
||||
return typeof collect === 'function' ? collect() : [];
|
||||
})()
|
||||
`, true);
|
||||
|
||||
if (!Array.isArray(result)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result
|
||||
.filter((entry) => entry && typeof entry === 'object')
|
||||
.map((entry) => normalizeImmediateRendererEntry(entry as Partial<PerfDiagEntry>));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeImmediateRendererEntry(entry: Partial<PerfDiagEntry>): PerfDiagEntry {
|
||||
return {
|
||||
collectedAt: Number(entry.collectedAt) || Date.now(),
|
||||
source: 'renderer',
|
||||
type: entry.type ?? 'session',
|
||||
payload: entry.payload ?? {}
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,16 @@
|
||||
export { isPerfDiagEnabled, PERF_DIAG_ENV, PERF_DIAG_FORCE_ENV } from './diagnostics.flags';
|
||||
export {
|
||||
clearHighMemoryAlert,
|
||||
readHighMemoryAlert,
|
||||
resolveHighMemoryAlertPath,
|
||||
writeHighMemoryAlert
|
||||
} from './high-memory-alert.store';
|
||||
export type { HighMemoryAlertRecord } from './high-memory-alert.store';
|
||||
export {
|
||||
exceedsHighMemoryThreshold,
|
||||
formatWorkingSetGb,
|
||||
HIGH_MEMORY_THRESHOLD_KB
|
||||
} from './high-memory-alert.rules';
|
||||
export {
|
||||
attachRendererDiagnosticsHooks,
|
||||
ensurePerfDiagIpcRegistered,
|
||||
|
||||
91
electron/diagnostics/session-context.collector.ts
Normal file
91
electron/diagnostics/session-context.collector.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import * as os from 'os';
|
||||
|
||||
export interface SessionWindowSnapshot {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string | null;
|
||||
focused: boolean;
|
||||
visible: boolean;
|
||||
destroyed: boolean;
|
||||
}
|
||||
|
||||
export interface SessionContextSnapshot {
|
||||
collectedAt: number;
|
||||
sessionStartedAt: number;
|
||||
uptimeMs: number;
|
||||
appVersion: string;
|
||||
electronVersion: string;
|
||||
chromeVersion: string;
|
||||
nodeVersion: string;
|
||||
platform: NodeJS.Platform;
|
||||
arch: string;
|
||||
osType: string;
|
||||
osRelease: string;
|
||||
osVersion: string | null;
|
||||
totalMemKb: number;
|
||||
freeMemKb: number;
|
||||
userDataPath: string;
|
||||
appPath: string;
|
||||
isPackaged: boolean;
|
||||
locale: string;
|
||||
windowCount: number;
|
||||
windows: SessionWindowSnapshot[];
|
||||
}
|
||||
|
||||
export function collectSessionContext(input: {
|
||||
sessionStartedAt: number;
|
||||
userDataPath: string;
|
||||
}): SessionContextSnapshot {
|
||||
const collectedAt = Date.now();
|
||||
|
||||
return {
|
||||
collectedAt,
|
||||
sessionStartedAt: input.sessionStartedAt,
|
||||
uptimeMs: Math.max(0, collectedAt - input.sessionStartedAt),
|
||||
appVersion: app.getVersion(),
|
||||
electronVersion: process.versions.electron ?? 'unknown',
|
||||
chromeVersion: process.versions.chrome ?? 'unknown',
|
||||
nodeVersion: process.versions.node ?? 'unknown',
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
osType: os.type(),
|
||||
osRelease: os.release(),
|
||||
osVersion: readOsVersion(),
|
||||
totalMemKb: Math.round(os.totalmem() / 1024),
|
||||
freeMemKb: Math.round(os.freemem() / 1024),
|
||||
userDataPath: input.userDataPath,
|
||||
appPath: app.getAppPath(),
|
||||
isPackaged: app.isPackaged,
|
||||
locale: app.getLocale(),
|
||||
windowCount: BrowserWindow.getAllWindows().length,
|
||||
windows: BrowserWindow.getAllWindows().map(collectWindowSnapshot)
|
||||
};
|
||||
}
|
||||
|
||||
function collectWindowSnapshot(window: BrowserWindow): SessionWindowSnapshot {
|
||||
let url: string | null = null;
|
||||
|
||||
try {
|
||||
url = window.webContents.getURL() || null;
|
||||
} catch {
|
||||
url = null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: window.id,
|
||||
title: window.getTitle(),
|
||||
url,
|
||||
focused: window.isFocused(),
|
||||
visible: window.isVisible(),
|
||||
destroyed: window.isDestroyed()
|
||||
};
|
||||
}
|
||||
|
||||
function readOsVersion(): string | null {
|
||||
try {
|
||||
return os.version?.() ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -259,6 +259,14 @@ export interface ElectronAPI {
|
||||
type: string;
|
||||
payload: Record<string, unknown>;
|
||||
}) => Promise<boolean>;
|
||||
getPendingHighMemoryAlert: () => Promise<{
|
||||
logFilePath: string;
|
||||
detectedAt: number;
|
||||
peakWorkingSetKb: number;
|
||||
sessionId: string;
|
||||
} | null>;
|
||||
acknowledgeHighMemoryAlert: () => Promise<boolean>;
|
||||
showLogFileInFolder: (filePath: string) => Promise<{ shown: boolean; reason?: string }>;
|
||||
getAppDataPath: () => Promise<string>;
|
||||
openCurrentDataFolder: () => Promise<boolean>;
|
||||
exportUserData: () => Promise<ExportUserDataResult>;
|
||||
@@ -400,6 +408,9 @@ const electronAPI: ElectronAPI = {
|
||||
getAppMetrics: () => ipcRenderer.invoke('get-app-metrics'),
|
||||
isPerfDiagEnabled: () => ipcRenderer.invoke('perf-diag-is-enabled'),
|
||||
reportPerfDiagSample: (entry) => ipcRenderer.invoke('perf-diag-report', entry),
|
||||
getPendingHighMemoryAlert: () => ipcRenderer.invoke('get-pending-high-memory-alert'),
|
||||
acknowledgeHighMemoryAlert: () => ipcRenderer.invoke('acknowledge-high-memory-alert'),
|
||||
showLogFileInFolder: (filePath) => ipcRenderer.invoke('show-log-file-in-folder', filePath),
|
||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
||||
openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'),
|
||||
exportUserData: () => ipcRenderer.invoke('export-user-data'),
|
||||
|
||||
105
server/src/websocket/handler-voice-disconnect.spec.ts
Normal file
105
server/src/websocket/handler-voice-disconnect.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import { WebSocket } from 'ws';
|
||||
import { connectedUsers } from './state';
|
||||
import { ConnectedUser } from './types';
|
||||
import { finalizeVoiceDisconnectForConnection } from './handler';
|
||||
|
||||
function createMockWs(): WebSocket & { sentMessages: string[] } {
|
||||
const sent: string[] = [];
|
||||
const ws = {
|
||||
readyState: WebSocket.OPEN,
|
||||
send: (data: string) => { sent.push(data); },
|
||||
close: () => {},
|
||||
terminate: () => {},
|
||||
sentMessages: sent
|
||||
} as unknown as WebSocket & { sentMessages: string[] };
|
||||
|
||||
return ws;
|
||||
}
|
||||
|
||||
function createConnectedUser(
|
||||
connectionId: string,
|
||||
overrides: Partial<ConnectedUser> = {}
|
||||
): ConnectedUser {
|
||||
const user: ConnectedUser = {
|
||||
oderId: 'user-1',
|
||||
ws: createMockWs(),
|
||||
authenticated: true,
|
||||
serverIds: new Set(['server-1']),
|
||||
displayName: 'Alice',
|
||||
lastPong: Date.now(),
|
||||
...overrides
|
||||
};
|
||||
|
||||
connectedUsers.set(connectionId, user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
function getSentMessages(user: ConnectedUser): string[] {
|
||||
return (user.ws as WebSocket & { sentMessages: string[] }).sentMessages;
|
||||
}
|
||||
|
||||
describe('finalizeVoiceDisconnectForConnection', () => {
|
||||
beforeEach(() => {
|
||||
connectedUsers.clear();
|
||||
});
|
||||
|
||||
it('broadcasts a cleared voice_state when a voice-active connection is removed', () => {
|
||||
createConnectedUser('conn-voice', {
|
||||
voiceActive: true,
|
||||
voiceStateSnapshot: {
|
||||
type: 'voice_state',
|
||||
serverId: 'server-1',
|
||||
voiceState: {
|
||||
isConnected: true,
|
||||
isMuted: true,
|
||||
isDeafened: false,
|
||||
roomId: 'voice-1',
|
||||
serverId: 'server-1'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const observer = createConnectedUser('conn-observer', { oderId: 'user-2' });
|
||||
|
||||
getSentMessages(observer).length = 0;
|
||||
|
||||
finalizeVoiceDisconnectForConnection('conn-voice');
|
||||
|
||||
const messages = getSentMessages(observer).map((raw) => JSON.parse(raw) as {
|
||||
type: string;
|
||||
voiceState?: { isConnected?: boolean; isMuted?: boolean; isDeafened?: boolean };
|
||||
});
|
||||
const voiceState = messages.find((message) => message.type === 'voice_state');
|
||||
|
||||
expect(voiceState).toMatchObject({
|
||||
type: 'voice_state',
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false
|
||||
}
|
||||
});
|
||||
|
||||
expect(connectedUsers.get('conn-voice')?.voiceActive).toBe(false);
|
||||
expect(connectedUsers.get('conn-voice')?.voiceStateSnapshot).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does nothing when the connection was not voice-active', () => {
|
||||
const observer = createConnectedUser('conn-observer', { oderId: 'user-2' });
|
||||
|
||||
createConnectedUser('conn-idle');
|
||||
|
||||
getSentMessages(observer).length = 0;
|
||||
|
||||
finalizeVoiceDisconnectForConnection('conn-idle');
|
||||
|
||||
expect(getSentMessages(observer)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -134,6 +134,59 @@ function clearVoiceActiveForOderId(oderId: string, exceptConnectionId?: string):
|
||||
});
|
||||
}
|
||||
|
||||
function readVoiceStateServerId(snapshot: Record<string, unknown> | undefined): string | undefined {
|
||||
if (!snapshot) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const nestedVoiceState = snapshot['voiceState'];
|
||||
|
||||
if (nestedVoiceState && typeof nestedVoiceState === 'object') {
|
||||
const nestedServerId = readMessageId((nestedVoiceState as { serverId?: unknown }).serverId);
|
||||
|
||||
if (nestedServerId) {
|
||||
return nestedServerId;
|
||||
}
|
||||
}
|
||||
|
||||
return readMessageId(snapshot['serverId']);
|
||||
}
|
||||
|
||||
/** Broadcast a cleared voice_state when a voice-active socket disappears without a graceful leave. */
|
||||
export function finalizeVoiceDisconnectForConnection(connectionId: string): void {
|
||||
const user = connectedUsers.get(connectionId);
|
||||
|
||||
if (!user?.authenticated || (!user.voiceActive && !user.voiceStateSnapshot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serverId = readVoiceStateServerId(user.voiceStateSnapshot) ?? user.viewedServerId;
|
||||
|
||||
if (serverId && user.serverIds.has(serverId)) {
|
||||
broadcastToServer(
|
||||
serverId,
|
||||
{
|
||||
type: 'voice_state',
|
||||
serverId,
|
||||
oderId: user.oderId,
|
||||
displayName: normalizeDisplayName(user.displayName),
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isSpeaking: false
|
||||
}
|
||||
},
|
||||
{ excludeConnectionId: connectionId }
|
||||
);
|
||||
}
|
||||
|
||||
user.voiceActive = false;
|
||||
user.voiceStateSnapshot = undefined;
|
||||
connectedUsers.set(connectionId, user);
|
||||
clearVoiceActiveForOderId(user.oderId, connectionId);
|
||||
}
|
||||
|
||||
function sendVoiceStateSnapshotToConnection(user: ConnectedUser, snapshot: Record<string, unknown>): void {
|
||||
user.ws.send(JSON.stringify({
|
||||
type: 'voice_state',
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
getServerIdsForOderId,
|
||||
isOderIdConnectedToServer
|
||||
} from './broadcast';
|
||||
import { handleWebSocketMessage } from './handler';
|
||||
import { handleWebSocketMessage, finalizeVoiceDisconnectForConnection } from './handler';
|
||||
|
||||
type IncomingWebSocketMessage = Parameters<typeof handleWebSocketMessage>[1];
|
||||
|
||||
@@ -26,6 +26,8 @@ function removeDeadConnection(connectionId: string): void {
|
||||
if (user) {
|
||||
console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`);
|
||||
|
||||
finalizeVoiceDisconnectForConnection(connectionId);
|
||||
|
||||
const remainingServerIds = getServerIdsForOderId(user.oderId, connectionId);
|
||||
|
||||
user.serverIds.forEach((sid) => {
|
||||
|
||||
@@ -15,6 +15,16 @@
|
||||
"downloadedMessage": "The update has already been downloaded. Restart the app when you're ready to finish applying it.",
|
||||
"updateSettings": "Update settings",
|
||||
"restartNow": "Restart now"
|
||||
},
|
||||
"highMemoryAlert": {
|
||||
"badge": "High memory usage",
|
||||
"title": "The app used {{usageGb}} GB of RAM last session",
|
||||
"message": "A diagnostics log was saved in your app data folder. You can open it now or copy the path if you want to share it with support.",
|
||||
"openLog": "Open log file",
|
||||
"showInFolder": "Show in folder",
|
||||
"copyPath": "Copy path",
|
||||
"dismiss": "Dismiss",
|
||||
"dismissAriaLabel": "Dismiss high memory alert"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,16 @@
|
||||
"downloadedMessage": "The update has already been downloaded. Restart the app when you're ready to finish applying it.",
|
||||
"updateSettings": "Update settings",
|
||||
"restartNow": "Restart now"
|
||||
},
|
||||
"highMemoryAlert": {
|
||||
"badge": "High memory usage",
|
||||
"title": "The app used {{usageGb}} GB of RAM last session",
|
||||
"message": "A diagnostics log was saved in your app data folder. You can open it now or copy the path if you want to share it with support.",
|
||||
"openLog": "Open log file",
|
||||
"showInFolder": "Show in folder",
|
||||
"copyPath": "Copy path",
|
||||
"dismiss": "Dismiss",
|
||||
"dismissAriaLabel": "Dismiss high memory alert"
|
||||
}
|
||||
},
|
||||
"attachment": {
|
||||
|
||||
@@ -167,6 +167,7 @@
|
||||
<app-incoming-call-modal />
|
||||
<app-screen-share-source-picker />
|
||||
<app-native-context-menu />
|
||||
<app-high-memory-alert-modal />
|
||||
<app-debug-console [showLauncher]="false" />
|
||||
<app-theme-picker-overlay />
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
loadLastViewedChatFromStorage
|
||||
} from './infrastructure/persistence';
|
||||
import { DesktopAppUpdateService } from './core/services/desktop-app-update.service';
|
||||
import { DesktopHighMemoryAlertService } from './core/services/desktop-high-memory-alert.service';
|
||||
import { ServerDirectoryFacade } from './domains/server-directory';
|
||||
import { NotificationsFacade } from './domains/notifications';
|
||||
import { TimeSyncService } from './core/services/time-sync.service';
|
||||
@@ -53,6 +54,7 @@ import { SettingsModalComponent } from './features/settings/settings-modal/setti
|
||||
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
|
||||
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
|
||||
import { NativeContextMenuComponent } from './features/shell/native-context-menu/native-context-menu.component';
|
||||
import { HighMemoryAlertModalComponent } from './features/shell/high-memory-alert-modal/high-memory-alert-modal.component';
|
||||
import { UsersActions } from './store/users/users.actions';
|
||||
import { RoomsActions } from './store/rooms/rooms.actions';
|
||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||
@@ -81,6 +83,7 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from './core/i18n';
|
||||
DebugConsoleComponent,
|
||||
ScreenShareSourcePickerComponent,
|
||||
NativeContextMenuComponent,
|
||||
HighMemoryAlertModalComponent,
|
||||
PrivateCallComponent,
|
||||
ThemeNodeDirective,
|
||||
ThemePickerOverlayComponent,
|
||||
@@ -103,6 +106,7 @@ export class App implements OnInit, OnDestroy {
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
desktopUpdates = inject(DesktopAppUpdateService);
|
||||
desktopUpdateState = this.desktopUpdates.state;
|
||||
desktopHighMemoryAlert = inject(DesktopHighMemoryAlertService);
|
||||
readonly databaseService = inject(DatabaseService);
|
||||
readonly router = inject(Router);
|
||||
readonly servers = inject(ServerDirectoryFacade);
|
||||
@@ -288,6 +292,7 @@ export class App implements OnInit, OnDestroy {
|
||||
// - desktop deep-link bridge (only relevant after first paint)
|
||||
// - background presence + game activity loops
|
||||
void this.desktopUpdates.initialize();
|
||||
void this.desktopHighMemoryAlert.initialize();
|
||||
void this.kickOffBackgroundBootstrap();
|
||||
|
||||
// The only thing we genuinely must await before deciding which route
|
||||
|
||||
@@ -251,6 +251,13 @@ export interface ElectronPerfDiagEntry {
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ElectronHighMemoryAlertRecord {
|
||||
logFilePath: string;
|
||||
detectedAt: number;
|
||||
peakWorkingSetKb: number;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface ElectronApi {
|
||||
linuxDisplayServer: string;
|
||||
minimizeWindow: () => void;
|
||||
@@ -272,6 +279,9 @@ export interface ElectronApi {
|
||||
getAppMetrics: () => Promise<ElectronAppMetricsSnapshot>;
|
||||
isPerfDiagEnabled?: () => Promise<boolean>;
|
||||
reportPerfDiagSample?: (entry: ElectronPerfDiagEntry) => Promise<boolean>;
|
||||
getPendingHighMemoryAlert?: () => Promise<ElectronHighMemoryAlertRecord | null>;
|
||||
acknowledgeHighMemoryAlert?: () => Promise<boolean>;
|
||||
showLogFileInFolder?: (filePath: string) => Promise<{ shown: boolean; reason?: string }>;
|
||||
getAppDataPath: () => Promise<string>;
|
||||
openCurrentDataFolder: () => Promise<boolean>;
|
||||
exportUserData: () => Promise<ExportUserDataResult>;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
|
||||
import {
|
||||
formatAppRamLabel,
|
||||
formatKilobytesAsGigabytes,
|
||||
formatKilobytesAsMegabytes,
|
||||
sumWorkingSetKb
|
||||
} from './electron-app-metrics.rules';
|
||||
@@ -38,6 +39,13 @@ describe('sumWorkingSetKb', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatKilobytesAsGigabytes', () => {
|
||||
it('formats totals in gigabytes with two decimals', () => {
|
||||
expect(formatKilobytesAsGigabytes(1536 * 1024)).toBe('1.50');
|
||||
expect(formatKilobytesAsGigabytes(2 * 1024 * 1024)).toBe('2.00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatKilobytesAsMegabytes', () => {
|
||||
it('rounds large values to whole megabytes', () => {
|
||||
expect(formatKilobytesAsMegabytes(412 * 1024)).toBe('412 MB');
|
||||
|
||||
@@ -36,6 +36,10 @@ export function formatKilobytesAsMegabytes(kilobytes: number): string {
|
||||
return `${megabytes.toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
export function formatKilobytesAsGigabytes(kilobytes: number): string {
|
||||
return (kilobytes / (1024 * 1024)).toFixed(2);
|
||||
}
|
||||
|
||||
export function formatAppRamLabel(snapshot: ElectronAppMetricsSnapshot): string | null {
|
||||
const totalKb = sumWorkingSetKb(snapshot.processes);
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
|
||||
import { PlatformService } from '../platform';
|
||||
import type { ElectronHighMemoryAlertRecord } from '../platform/electron/electron-api.models';
|
||||
import { ElectronBridgeService } from '../platform/electron/electron-bridge.service';
|
||||
import { formatKilobytesAsGigabytes } from '../platform/electron/electron-app-metrics.rules';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DesktopHighMemoryAlertService {
|
||||
private readonly platform = inject(PlatformService);
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
readonly pendingAlert = signal<ElectronHighMemoryAlertRecord | null>(null);
|
||||
|
||||
readonly peakUsageGb = computed(() => {
|
||||
const alert = this.pendingAlert();
|
||||
|
||||
return alert ? formatKilobytesAsGigabytes(alert.peakWorkingSetKb) : null;
|
||||
});
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!this.platform.isElectron) {
|
||||
return;
|
||||
}
|
||||
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
if (!api?.getPendingHighMemoryAlert) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alert = await api.getPendingHighMemoryAlert();
|
||||
|
||||
if (alert) {
|
||||
this.pendingAlert.set(alert);
|
||||
}
|
||||
}
|
||||
|
||||
async dismiss(): Promise<void> {
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
await api?.acknowledgeHighMemoryAlert?.();
|
||||
this.pendingAlert.set(null);
|
||||
}
|
||||
|
||||
async openLogFile(): Promise<void> {
|
||||
const alert = this.pendingAlert();
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
if (!alert?.logFilePath || !api?.openFilePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
await api.openFilePath(alert.logFilePath);
|
||||
}
|
||||
|
||||
async showLogFileInFolder(): Promise<void> {
|
||||
const alert = this.pendingAlert();
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
if (!alert?.logFilePath || !api?.showLogFileInFolder) {
|
||||
return;
|
||||
}
|
||||
|
||||
await api.showLogFileInFolder(alert.logFilePath);
|
||||
}
|
||||
|
||||
async copyLogPath(): Promise<void> {
|
||||
const alert = this.pendingAlert();
|
||||
|
||||
if (!alert?.logFilePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(alert.logFilePath);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { VoiceState } from '../../../../shared-kernel';
|
||||
import {
|
||||
isLocalVoiceOwner,
|
||||
isVoiceOnAnotherClient,
|
||||
shouldApplyRemoteVoiceStateToCurrentUser,
|
||||
shouldTransmitVoice
|
||||
} from './client-voice-session.rules';
|
||||
|
||||
@@ -51,4 +52,54 @@ describe('client-voice-session.rules', () => {
|
||||
|
||||
expect(shouldTransmitVoice(voiceState, localClientInstanceId)).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores stale self disconnect updates while this client actively owns voice', () => {
|
||||
const voiceState: VoiceState = {
|
||||
isConnected: true,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isSpeaking: false,
|
||||
roomId: 'vc-general',
|
||||
serverId: 'server-1',
|
||||
clientInstanceId: localClientInstanceId
|
||||
};
|
||||
|
||||
expect(shouldApplyRemoteVoiceStateToCurrentUser(
|
||||
voiceState,
|
||||
{ isConnected: false },
|
||||
localClientInstanceId,
|
||||
true
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('applies self disconnect updates when this client is not actively transmitting', () => {
|
||||
expect(shouldApplyRemoteVoiceStateToCurrentUser(
|
||||
{
|
||||
isConnected: true,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isSpeaking: false,
|
||||
clientInstanceId: localClientInstanceId
|
||||
},
|
||||
{ isConnected: false },
|
||||
localClientInstanceId,
|
||||
false
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores self disconnect echoes during the join race before clientInstanceId is stored', () => {
|
||||
expect(shouldApplyRemoteVoiceStateToCurrentUser(
|
||||
{
|
||||
isConnected: true,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isSpeaking: false,
|
||||
roomId: 'vc-general',
|
||||
serverId: 'server-1'
|
||||
},
|
||||
{ isConnected: false },
|
||||
localClientInstanceId,
|
||||
true
|
||||
)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,3 +32,29 @@ export function shouldTransmitVoice(
|
||||
|
||||
return voiceState.clientInstanceId === clientInstanceId;
|
||||
}
|
||||
|
||||
/** Ignore stale P2P disconnect echoes for the current user while this client actively owns voice. */
|
||||
export function shouldApplyRemoteVoiceStateToCurrentUser(
|
||||
currentVoiceState: Pick<VoiceState, 'isConnected' | 'clientInstanceId'> | null | undefined,
|
||||
incoming: Partial<VoiceState>,
|
||||
localClientInstanceId: string,
|
||||
isLocallyVoiceActive: boolean
|
||||
): boolean {
|
||||
if (incoming.isConnected !== false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isLocallyVoiceActive) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isLocalVoiceOwner(currentVoiceState, localClientInstanceId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!currentVoiceState?.clientInstanceId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
@if (alertService.pendingAlert(); as alert) {
|
||||
<app-modal-backdrop
|
||||
[zIndex]="120"
|
||||
[ariaLabel]="'app.highMemoryAlert.dismissAriaLabel' | translate"
|
||||
(dismissed)="dismiss()"
|
||||
/>
|
||||
|
||||
<div
|
||||
appThemeNode="highMemoryAlertDialog"
|
||||
class="fixed inset-0 z-[121] flex items-center justify-center px-4"
|
||||
role="alertdialog"
|
||||
[attr.aria-labelledby]="'high-memory-alert-title'"
|
||||
[attr.aria-describedby]="'high-memory-alert-description'"
|
||||
>
|
||||
<div class="relative w-full max-w-lg rounded-xl border border-border bg-card p-4 shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
(click)="dismiss()"
|
||||
class="absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
[attr.aria-label]="'app.highMemoryAlert.dismissAriaLabel' | translate"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-destructive">
|
||||
{{ 'app.highMemoryAlert.badge' | translate }}
|
||||
</p>
|
||||
|
||||
<h2
|
||||
id="high-memory-alert-title"
|
||||
class="mt-1 pr-10 text-base font-semibold text-foreground"
|
||||
>
|
||||
{{ 'app.highMemoryAlert.title' | translate:{ usageGb: alertService.peakUsageGb() } }}
|
||||
</h2>
|
||||
|
||||
<p
|
||||
id="high-memory-alert-description"
|
||||
class="mt-2 pr-2 text-sm leading-6 text-muted-foreground"
|
||||
>
|
||||
{{ 'app.highMemoryAlert.message' | translate }}
|
||||
</p>
|
||||
|
||||
<p class="mt-3 break-all rounded-lg border border-border/70 bg-secondary/40 px-3 py-2 font-mono text-[11px] leading-5 text-muted-foreground">
|
||||
{{ alert.logFilePath }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
(click)="openLogFile()"
|
||||
class="inline-flex items-center rounded-lg bg-primary px-3 py-2 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
{{ 'app.highMemoryAlert.openLog' | translate }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="showLogFileInFolder()"
|
||||
class="inline-flex items-center rounded-lg border border-border bg-secondary px-3 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
{{ 'app.highMemoryAlert.showInFolder' | translate }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="copyLogPath()"
|
||||
class="inline-flex items-center rounded-lg border border-border bg-secondary px-3 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
{{ 'app.highMemoryAlert.copyPath' | translate }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="dismiss()"
|
||||
class="inline-flex items-center rounded-lg border border-border px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
>
|
||||
{{ 'app.highMemoryAlert.dismiss' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideX } from '@ng-icons/lucide';
|
||||
|
||||
import { DesktopHighMemoryAlertService } from '../../../core/services/desktop-high-memory-alert.service';
|
||||
import { ThemeNodeDirective } from '../../../domains/theme';
|
||||
import { APP_TRANSLATE_IMPORTS } from '../../../core/i18n';
|
||||
import { ModalBackdropComponent } from '../../../shared/components/modal-backdrop/modal-backdrop.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-high-memory-alert-modal',
|
||||
standalone: true,
|
||||
imports: [
|
||||
NgIcon,
|
||||
ThemeNodeDirective,
|
||||
ModalBackdropComponent,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideX
|
||||
})
|
||||
],
|
||||
templateUrl: './high-memory-alert-modal.component.html',
|
||||
host: {
|
||||
style: 'display: contents;'
|
||||
}
|
||||
})
|
||||
export class HighMemoryAlertModalComponent {
|
||||
readonly alertService = inject(DesktopHighMemoryAlertService);
|
||||
|
||||
async dismiss(): Promise<void> {
|
||||
await this.alertService.dismiss();
|
||||
}
|
||||
|
||||
openLogFile(): void {
|
||||
void this.alertService.openLogFile();
|
||||
}
|
||||
|
||||
showLogFileInFolder(): void {
|
||||
void this.alertService.showLogFileInFolder();
|
||||
}
|
||||
|
||||
copyLogPath(): void {
|
||||
void this.alertService.copyLogPath();
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,11 @@ import type { ElectronApi } from '../../core/platform/electron/electron-api.mode
|
||||
import { PerfDiagnosticsCollector, publishRendererDiagnosticsSample } from './diagnostics.collector';
|
||||
import type { PerfDiagEntry, PerfDiagReporter } from './diagnostics.models';
|
||||
|
||||
declare global {
|
||||
// Registered for synchronous main-process sampling at high-memory threshold.
|
||||
var __collectPerfDiagSample: (() => PerfDiagEntry[]) | undefined;
|
||||
}
|
||||
|
||||
const SAMPLE_INTERVAL_MS = 10_000;
|
||||
|
||||
let started = false;
|
||||
@@ -36,6 +41,22 @@ export async function bootstrapPerfDiagnostics(
|
||||
|
||||
started = true;
|
||||
|
||||
let immediateSampleCollector: PerfDiagnosticsCollector | null = null;
|
||||
|
||||
runInInjectionContext(injector, () => {
|
||||
immediateSampleCollector = inject(PerfDiagnosticsCollector);
|
||||
});
|
||||
|
||||
globalThis.__collectPerfDiagSample = () => {
|
||||
if (!immediateSampleCollector) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sample = immediateSampleCollector.collectSample();
|
||||
|
||||
return sample ? immediateSampleCollector.buildEntries(sample) : [];
|
||||
};
|
||||
|
||||
const reporter: PerfDiagReporter = {
|
||||
report: (entry: PerfDiagEntry) => reportSample(entry)
|
||||
};
|
||||
@@ -92,5 +113,6 @@ function stopPerfDiagnosticsSampling(): void {
|
||||
sampleTimer = null;
|
||||
}
|
||||
|
||||
delete globalThis.__collectPerfDiagSample;
|
||||
started = false;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ export type PerfDiagSource = 'main' | 'renderer';
|
||||
|
||||
export type PerfDiagEntryType =
|
||||
| 'session'
|
||||
| 'environment'
|
||||
| 'process'
|
||||
| 'store'
|
||||
| 'components'
|
||||
| 'heap'
|
||||
| 'high-memory'
|
||||
| 'crash'
|
||||
| 'unresponsive';
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import { NoiseReductionManager } from './noise-reduction.manager';
|
||||
|
||||
describe('NoiseReductionManager', () => {
|
||||
it('replaces the input stream when noise reduction is already enabled', async () => {
|
||||
const manager = new NoiseReductionManager({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn()
|
||||
} as never);
|
||||
const rawStream = createFakeMediaStream('fresh-track');
|
||||
const replacedStream = createFakeMediaStream('clean-track');
|
||||
|
||||
vi.spyOn(manager, 'replaceInputStream').mockResolvedValue(replacedStream);
|
||||
|
||||
const enabledManager = manager as NoiseReductionManager & {
|
||||
_isEnabled: boolean;
|
||||
destinationNode: { stream: MediaStream };
|
||||
};
|
||||
|
||||
enabledManager._isEnabled = true;
|
||||
enabledManager.destinationNode = { stream: createFakeMediaStream('stale-track') };
|
||||
|
||||
await expect(manager.enable(rawStream)).resolves.toBe(replacedStream);
|
||||
expect(manager.replaceInputStream).toHaveBeenCalledWith(rawStream);
|
||||
});
|
||||
});
|
||||
|
||||
function createFakeMediaStream(trackId: string): MediaStream {
|
||||
return {
|
||||
getAudioTracks: () => [{ id: trackId }],
|
||||
getVideoTracks: () => [],
|
||||
getTracks: () => [{ id: trackId }]
|
||||
} as MediaStream;
|
||||
}
|
||||
@@ -66,8 +66,8 @@ export class NoiseReductionManager {
|
||||
*/
|
||||
async enable(rawStream: MediaStream): Promise<MediaStream> {
|
||||
if (this._isEnabled && this.destinationNode) {
|
||||
this.logger.info('Noise reduction already enabled, returning existing clean stream');
|
||||
return this.destinationNode.stream;
|
||||
this.logger.info('Noise reduction already enabled, replacing input stream');
|
||||
return this.replaceInputStream(rawStream);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { selectCurrentRoom, selectSavedRooms } from './rooms.selectors';
|
||||
import { normalizeRoomAccessControl, resolveLegacyRole } from '../../domains/access-control';
|
||||
import {
|
||||
areRoomMembersEqual,
|
||||
collapseSelfRoomMembers,
|
||||
findRoomMember,
|
||||
mergeRoomMembers,
|
||||
pruneRoomMembers,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
updateRoomMemberRole,
|
||||
upsertRoomMember
|
||||
} from './room-members.helpers';
|
||||
import { resolveRoomMemberActorIdentity } from './room-member-identity.rules';
|
||||
import { SignalServerAuthService } from '../../domains/authentication/application/services/signal-server-auth.service';
|
||||
import { isSelfPresenceUserId } from '../../domains/authentication/domain/logic/self-presence-identity.rules';
|
||||
|
||||
@@ -63,7 +65,7 @@ export class RoomMembersSyncEffects {
|
||||
room.members ?? [],
|
||||
this.buildCurrentUserMember(room, currentUser, true)
|
||||
);
|
||||
const actions = this.createRoomMemberUpdateActions(room, members);
|
||||
const actions = this.createRoomMemberUpdateActions(room, members, currentUser, true);
|
||||
|
||||
return actions.length > 0 ? actions : EMPTY;
|
||||
})
|
||||
@@ -93,7 +95,7 @@ export class RoomMembersSyncEffects {
|
||||
currentRoom.members ?? [],
|
||||
this.buildCurrentUserMember(currentRoom, currentUser, true)
|
||||
);
|
||||
const actions = this.createRoomMemberUpdateActions(currentRoom, members);
|
||||
const actions = this.createRoomMemberUpdateActions(currentRoom, members, currentUser, true);
|
||||
|
||||
return actions.length > 0 ? actions : EMPTY;
|
||||
})
|
||||
@@ -104,13 +106,20 @@ export class RoomMembersSyncEffects {
|
||||
syncRoleChangesIntoCurrentRoom$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(UsersActions.updateUserRole),
|
||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||
mergeMap(([{ userId, role }, currentRoom]) => {
|
||||
withLatestFrom(
|
||||
this.store.select(selectCurrentRoom),
|
||||
this.store.select(selectCurrentUser)
|
||||
),
|
||||
mergeMap(([
|
||||
{ userId, role },
|
||||
currentRoom,
|
||||
currentUser
|
||||
]) => {
|
||||
if (!currentRoom)
|
||||
return EMPTY;
|
||||
|
||||
const members = updateRoomMemberRole(currentRoom.members ?? [], userId, role);
|
||||
const actions = this.createRoomMemberUpdateActions(currentRoom, members);
|
||||
const actions = this.createRoomMemberUpdateActions(currentRoom, members, currentUser, true);
|
||||
|
||||
return actions.length > 0 ? actions : EMPTY;
|
||||
})
|
||||
@@ -194,7 +203,12 @@ export class RoomMembersSyncEffects {
|
||||
members = upsertRoomMember(members, this.buildPresenceMember(room, user));
|
||||
}
|
||||
|
||||
const actions = this.createRoomMemberUpdateActions(room, members);
|
||||
const actions = this.createRoomMemberUpdateActions(
|
||||
room,
|
||||
members,
|
||||
currentUser ?? null,
|
||||
currentRoom?.id === room.id
|
||||
);
|
||||
|
||||
return actions.length > 0 ? actions : EMPTY;
|
||||
}
|
||||
@@ -211,7 +225,12 @@ export class RoomMembersSyncEffects {
|
||||
room.members ?? [],
|
||||
this.buildPresenceMember(room, joinedUser)
|
||||
);
|
||||
const actions = this.createRoomMemberUpdateActions(room, members);
|
||||
const actions = this.createRoomMemberUpdateActions(
|
||||
room,
|
||||
members,
|
||||
currentUser ?? null,
|
||||
currentRoom?.id === room.id
|
||||
);
|
||||
|
||||
return actions.length > 0 ? actions : EMPTY;
|
||||
}
|
||||
@@ -221,7 +240,12 @@ export class RoomMembersSyncEffects {
|
||||
return EMPTY;
|
||||
|
||||
const members = touchRoomMemberLastSeen(room.members ?? [], signalingMessage.oderId, Date.now());
|
||||
const actions = this.createRoomMemberUpdateActions(room, members);
|
||||
const actions = this.createRoomMemberUpdateActions(
|
||||
room,
|
||||
members,
|
||||
currentUser ?? null,
|
||||
currentRoom?.id === room.id
|
||||
);
|
||||
|
||||
return actions.length > 0 ? actions : EMPTY;
|
||||
}
|
||||
@@ -339,15 +363,30 @@ export class RoomMembersSyncEffects {
|
||||
}
|
||||
|
||||
private buildCurrentUserMember(room: Room, currentUser: User, isCurrentRoom: boolean): RoomMember {
|
||||
const existingMember = findRoomMember(room.members ?? [], currentUser.oderId || currentUser.id);
|
||||
const role = room.hostId === currentUser.id
|
||||
const selfIds = this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(currentUser, room.sourceUrl);
|
||||
const identity = resolveRoomMemberActorIdentity(
|
||||
room,
|
||||
currentUser,
|
||||
(serverUrl, fallback) => this.signalServerAuth.resolveActorUserIdForServer(serverUrl, fallback),
|
||||
(user, roomSourceUrl) => this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(user, roomSourceUrl)
|
||||
);
|
||||
const actorUserId = identity.actorUserId;
|
||||
const existingMember = findRoomMember(room.members ?? [], actorUserId)
|
||||
?? (room.members ?? []).find((member) =>
|
||||
isSelfPresenceUserId(member.oderId, selfIds) || isSelfPresenceUserId(member.id, selfIds)
|
||||
);
|
||||
const isHost = room.hostId === currentUser.id
|
||||
|| room.hostId === currentUser.oderId
|
||||
|| room.hostId === actorUserId;
|
||||
const role = isHost
|
||||
? 'host'
|
||||
: (isCurrentRoom ? currentUser.role : existingMember?.role ?? 'member');
|
||||
const seenAt = existingMember?.lastSeenAt ?? currentUser.joinedAt ?? Date.now();
|
||||
|
||||
return {
|
||||
...roomMemberFromUser(currentUser, seenAt, role),
|
||||
id: existingMember?.id ?? currentUser.id,
|
||||
id: identity.existingMemberId ?? actorUserId,
|
||||
oderId: actorUserId,
|
||||
joinedAt: existingMember?.joinedAt ?? currentUser.joinedAt ?? seenAt,
|
||||
avatarUrl: currentUser.avatarUrl ?? existingMember?.avatarUrl,
|
||||
role
|
||||
@@ -375,10 +414,24 @@ export class RoomMembersSyncEffects {
|
||||
};
|
||||
}
|
||||
|
||||
private createRoomMemberUpdateActions(room: Room, members: RoomMember[]): Action[] {
|
||||
return areRoomMembersEqual(room.members ?? [], members)
|
||||
private createRoomMemberUpdateActions(
|
||||
room: Room,
|
||||
members: RoomMember[],
|
||||
currentUser: User | null = null,
|
||||
isCurrentRoom = false
|
||||
): Action[] {
|
||||
const normalizedMembers = currentUser
|
||||
? collapseSelfRoomMembers(
|
||||
members,
|
||||
this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(currentUser, room.sourceUrl),
|
||||
this.buildCurrentUserMember(room, currentUser, isCurrentRoom),
|
||||
Date.now()
|
||||
)
|
||||
: pruneRoomMembers(members);
|
||||
|
||||
return areRoomMembersEqual(room.members ?? [], normalizedMembers)
|
||||
? []
|
||||
: [RoomsActions.updateRoom({ roomId: room.id, changes: { members } })];
|
||||
: [RoomsActions.updateRoom({ roomId: room.id, changes: { members: normalizedMembers } })];
|
||||
}
|
||||
|
||||
private resolveRoleSyncRoom(
|
||||
@@ -491,7 +544,7 @@ export class RoomMembersSyncEffects {
|
||||
members
|
||||
});
|
||||
|
||||
return this.createRoomMemberUpdateActions(room, members);
|
||||
return this.createRoomMemberUpdateActions(room, members, currentUser, isCurrentRoom);
|
||||
}
|
||||
|
||||
private handleMemberRoster(
|
||||
@@ -514,7 +567,12 @@ export class RoomMembersSyncEffects {
|
||||
);
|
||||
}
|
||||
|
||||
const actions = this.createRoomMemberUpdateActions(room, members);
|
||||
const actions = this.createRoomMemberUpdateActions(
|
||||
room,
|
||||
members,
|
||||
currentUser,
|
||||
currentRoom?.id === room.id
|
||||
);
|
||||
const currentUserId = currentUser?.oderId || currentUser?.id;
|
||||
|
||||
for (const member of members) {
|
||||
@@ -551,7 +609,9 @@ export class RoomMembersSyncEffects {
|
||||
|
||||
const actions = this.createRoomMemberUpdateActions(
|
||||
room,
|
||||
removeRoomMember(room.members ?? [], event.targetUserId, event.oderId)
|
||||
removeRoomMember(room.members ?? [], event.targetUserId, event.oderId),
|
||||
null,
|
||||
currentRoom?.id === room.id
|
||||
);
|
||||
const departedUserId = event.oderId ?? event.targetUserId;
|
||||
|
||||
@@ -652,7 +712,9 @@ export class RoomMembersSyncEffects {
|
||||
|
||||
const actions = this.createRoomMemberUpdateActions(
|
||||
room,
|
||||
updateRoomMemberRole(room.members ?? [], event.targetUserId, event.role)
|
||||
updateRoomMemberRole(room.members ?? [], event.targetUserId, event.role),
|
||||
null,
|
||||
currentRoom?.id === room.id
|
||||
);
|
||||
|
||||
if (currentRoom?.id === room.id) {
|
||||
|
||||
@@ -183,8 +183,12 @@ function mergeMembers(
|
||||
const avatarFields = mergeAvatarFields(normalizedExisting, normalizedIncoming, preferIncoming);
|
||||
|
||||
return {
|
||||
id: normalizedExisting.id || normalizedIncoming.id,
|
||||
oderId: normalizedIncoming.oderId || normalizedExisting.oderId,
|
||||
id: preferIncoming
|
||||
? (normalizedIncoming.id || normalizedExisting.id)
|
||||
: (normalizedExisting.id || normalizedIncoming.id),
|
||||
oderId: preferIncoming
|
||||
? (normalizedIncoming.oderId || normalizedExisting.oderId)
|
||||
: (normalizedExisting.oderId || normalizedIncoming.oderId),
|
||||
username: preferIncoming
|
||||
? (normalizedIncoming.username || normalizedExisting.username)
|
||||
: (normalizedExisting.username || normalizedIncoming.username),
|
||||
@@ -241,6 +245,48 @@ export function roomMemberFromUser(
|
||||
);
|
||||
}
|
||||
|
||||
function profileDedupKey(member: RoomMember): string {
|
||||
return `${member.displayName.trim().toLowerCase()}::${member.username.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
function deduplicateMatchingProfiles(members: RoomMember[], now: number): RoomMember[] {
|
||||
const profiles = new Map<string, RoomMember>();
|
||||
|
||||
for (const member of members) {
|
||||
const profileKey = profileDedupKey(member);
|
||||
|
||||
profiles.set(profileKey, mergeMembers(profiles.get(profileKey), member, now));
|
||||
}
|
||||
|
||||
return Array.from(profiles.values());
|
||||
}
|
||||
|
||||
/** Remove every roster entry whose id or oderId matches one of the supplied ids. */
|
||||
export function removeRoomMembersMatchingIds(
|
||||
members: RoomMember[] = [],
|
||||
ids: ReadonlySet<string>
|
||||
): RoomMember[] {
|
||||
if (ids.size === 0) {
|
||||
return pruneRoomMembers(members);
|
||||
}
|
||||
|
||||
return pruneRoomMembers(members).filter(
|
||||
(member) => !ids.has(member.id) && !ids.has(member.oderId || '')
|
||||
);
|
||||
}
|
||||
|
||||
/** Replace stale self aliases with the canonical actor member for this room. */
|
||||
export function collapseSelfRoomMembers(
|
||||
members: RoomMember[] = [],
|
||||
selfIds: ReadonlySet<string>,
|
||||
canonicalMember: RoomMember,
|
||||
now = Date.now()
|
||||
): RoomMember[] {
|
||||
const withoutSelfAliases = removeRoomMembersMatchingIds(members, selfIds);
|
||||
|
||||
return upsertRoomMember(withoutSelfAliases, canonicalMember, now);
|
||||
}
|
||||
|
||||
/** Deduplicate, sanitize, sort, and prune stale room members. */
|
||||
export function pruneRoomMembers(
|
||||
members: RoomMember[] = [],
|
||||
@@ -266,7 +312,10 @@ export function pruneRoomMembers(
|
||||
);
|
||||
}
|
||||
|
||||
return Array.from(deduplicatedMembers.values()).sort(compareMembers);
|
||||
return deduplicateMatchingProfiles(
|
||||
Array.from(deduplicatedMembers.values()),
|
||||
now
|
||||
).sort(compareMembers);
|
||||
}
|
||||
|
||||
/** Upsert a member into a room roster while preserving the best known data. */
|
||||
|
||||
@@ -44,7 +44,7 @@ import { hasRoomBanForUser } from '../../domains/access-control';
|
||||
import { RECONNECT_SOUND_GRACE_MS } from '../../core/constants';
|
||||
import { VoiceSessionFacade, VoiceClientTakeoverService } from '../../domains/voice-session';
|
||||
import { ClientInstanceService } from '../../core/platform/client-instance.service';
|
||||
import { isVoiceOnAnotherClient } from '../../domains/voice-session/domain/logic/client-voice-session.rules';
|
||||
import { isVoiceOnAnotherClient, shouldApplyRemoteVoiceStateToCurrentUser } from '../../domains/voice-session';
|
||||
import { SignalServerAuthService } from '../../domains/authentication/application/services/signal-server-auth.service';
|
||||
import { isSelfPresenceUserId } from '../../domains/authentication/domain/logic/self-presence-identity.rules';
|
||||
import {
|
||||
@@ -563,6 +563,18 @@ export class RoomStateSyncEffects {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isCurrentUserEvent
|
||||
&& !shouldApplyRemoteVoiceStateToCurrentUser(
|
||||
currentUser?.voiceState,
|
||||
vs,
|
||||
localClientInstanceId,
|
||||
this.webrtc.isVoiceConnected()
|
||||
)
|
||||
) {
|
||||
return presenceRefreshAction ? of(presenceRefreshAction) : EMPTY;
|
||||
}
|
||||
|
||||
const actions: Action[] = [];
|
||||
|
||||
if (presenceRefreshAction) {
|
||||
|
||||
92
toju-app/src/app/store/users/user-voice-state.rules.spec.ts
Normal file
92
toju-app/src/app/store/users/user-voice-state.rules.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import { mergeVoiceStateUpdate } from './user-voice-state.rules';
|
||||
|
||||
describe('mergeVoiceStateUpdate', () => {
|
||||
it('clears mute and deafen flags when a user disconnects from voice', () => {
|
||||
const next = mergeVoiceStateUpdate(
|
||||
{
|
||||
isConnected: true,
|
||||
isMuted: true,
|
||||
isDeafened: true,
|
||||
isSpeaking: true,
|
||||
roomId: 'voice-1',
|
||||
serverId: 'server-1'
|
||||
},
|
||||
{ isConnected: false }
|
||||
);
|
||||
|
||||
expect(next).toMatchObject({
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isSpeaking: false
|
||||
});
|
||||
});
|
||||
|
||||
it('does not carry stale mute state into a fresh voice connection', () => {
|
||||
const next = mergeVoiceStateUpdate(
|
||||
{
|
||||
isConnected: false,
|
||||
isMuted: true,
|
||||
isDeafened: true,
|
||||
isSpeaking: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined
|
||||
},
|
||||
{
|
||||
isConnected: true,
|
||||
roomId: 'voice-1',
|
||||
serverId: 'server-1'
|
||||
}
|
||||
);
|
||||
|
||||
expect(next).toMatchObject({
|
||||
isConnected: true,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isSpeaking: false,
|
||||
roomId: 'voice-1',
|
||||
serverId: 'server-1'
|
||||
});
|
||||
});
|
||||
|
||||
it('honors an explicit mute flag on reconnect when provided', () => {
|
||||
const next = mergeVoiceStateUpdate(
|
||||
{
|
||||
isConnected: false,
|
||||
isMuted: true,
|
||||
isDeafened: false,
|
||||
isSpeaking: false
|
||||
},
|
||||
{
|
||||
isConnected: true,
|
||||
isMuted: true,
|
||||
roomId: 'voice-1',
|
||||
serverId: 'server-1'
|
||||
}
|
||||
);
|
||||
|
||||
expect(next.isMuted).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves mute toggles during an active voice session', () => {
|
||||
const next = mergeVoiceStateUpdate(
|
||||
{
|
||||
isConnected: true,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isSpeaking: false,
|
||||
roomId: 'voice-1',
|
||||
serverId: 'server-1'
|
||||
},
|
||||
{ isMuted: true }
|
||||
);
|
||||
|
||||
expect(next.isMuted).toBe(true);
|
||||
expect(next.isConnected).toBe(true);
|
||||
});
|
||||
});
|
||||
51
toju-app/src/app/store/users/user-voice-state.rules.ts
Normal file
51
toju-app/src/app/store/users/user-voice-state.rules.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { VoiceState } from '../../shared-kernel';
|
||||
|
||||
const DEFAULT_VOICE_STATE: VoiceState = {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isSpeaking: false
|
||||
};
|
||||
|
||||
/** Merge a partial voice-state patch without leaking stale mute/deafen flags across sessions. */
|
||||
export function mergeVoiceStateUpdate(
|
||||
previous: VoiceState | undefined,
|
||||
update: Partial<VoiceState>
|
||||
): VoiceState {
|
||||
const prev = previous ?? DEFAULT_VOICE_STATE;
|
||||
const hasRoomId = Object.prototype.hasOwnProperty.call(update, 'roomId');
|
||||
const hasServerId = Object.prototype.hasOwnProperty.call(update, 'serverId');
|
||||
const hasClientInstanceId = Object.prototype.hasOwnProperty.call(update, 'clientInstanceId');
|
||||
const hasIsMuted = Object.prototype.hasOwnProperty.call(update, 'isMuted');
|
||||
const hasIsDeafened = Object.prototype.hasOwnProperty.call(update, 'isDeafened');
|
||||
const hasIsSpeaking = Object.prototype.hasOwnProperty.call(update, 'isSpeaking');
|
||||
const nextConnected = update.isConnected ?? prev.isConnected;
|
||||
const isDisconnecting = update.isConnected === false;
|
||||
const isReconnecting = nextConnected === true && prev.isConnected === false;
|
||||
const resolveSessionFlag = (
|
||||
key: 'isMuted' | 'isDeafened' | 'isSpeaking',
|
||||
hasExplicit: boolean
|
||||
): boolean => {
|
||||
if (isDisconnecting) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isReconnecting) {
|
||||
return hasExplicit ? update[key] === true : false;
|
||||
}
|
||||
|
||||
return update[key] ?? prev[key];
|
||||
};
|
||||
|
||||
return {
|
||||
isConnected: nextConnected,
|
||||
isMuted: resolveSessionFlag('isMuted', hasIsMuted),
|
||||
isDeafened: resolveSessionFlag('isDeafened', hasIsDeafened),
|
||||
isSpeaking: resolveSessionFlag('isSpeaking', hasIsSpeaking),
|
||||
isMutedByAdmin: update.isMutedByAdmin ?? prev.isMutedByAdmin,
|
||||
volume: update.volume ?? prev.volume,
|
||||
roomId: hasRoomId ? update.roomId : prev.roomId,
|
||||
serverId: hasServerId ? update.serverId : prev.serverId,
|
||||
clientInstanceId: hasClientInstanceId ? update.clientInstanceId : prev.clientInstanceId
|
||||
};
|
||||
}
|
||||
@@ -373,8 +373,8 @@ describe('users reducer - status', () => {
|
||||
presenceServerIds: ['s1'],
|
||||
voiceState: {
|
||||
isConnected: true,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isMuted: true,
|
||||
isDeafened: true,
|
||||
isSpeaking: true,
|
||||
roomId: 'voice-1',
|
||||
serverId: 's1'
|
||||
@@ -390,6 +390,8 @@ describe('users reducer - status', () => {
|
||||
expect(state.entities['u6']?.presenceServerIds).toBeUndefined();
|
||||
expect(state.entities['u6']?.isOnline).toBe(false);
|
||||
expect(state.entities['u6']?.voiceState?.isConnected).toBe(false);
|
||||
expect(state.entities['u6']?.voiceState?.isMuted).toBe(false);
|
||||
expect(state.entities['u6']?.voiceState?.isDeafened).toBe(false);
|
||||
expect(state.entities['u6']?.voiceState?.roomId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
UserStatus
|
||||
} from '../../shared-kernel';
|
||||
import { UsersActions } from './users.actions';
|
||||
import { mergeVoiceStateUpdate } from './user-voice-state.rules';
|
||||
|
||||
function normalizePresenceServerIds(serverIds: readonly string[] | undefined): string[] | undefined {
|
||||
if (!Array.isArray(serverIds)) {
|
||||
@@ -148,7 +149,8 @@ function buildDisconnectedVoiceState(user: User): User['voiceState'] {
|
||||
isDeafened: false,
|
||||
isSpeaking: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined
|
||||
serverId: undefined,
|
||||
clientInstanceId: undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -510,37 +512,17 @@ export const usersReducer = createReducer(
|
||||
state
|
||||
)
|
||||
),
|
||||
on(UsersActions.updateVoiceState, (state, { userId, voiceState }) => {
|
||||
const prev = state.entities[userId]?.voiceState || {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isSpeaking: false
|
||||
};
|
||||
const hasRoomId = Object.prototype.hasOwnProperty.call(voiceState, 'roomId');
|
||||
const hasServerId = Object.prototype.hasOwnProperty.call(voiceState, 'serverId');
|
||||
const hasClientInstanceId = Object.prototype.hasOwnProperty.call(voiceState, 'clientInstanceId');
|
||||
|
||||
return usersAdapter.updateOne(
|
||||
on(UsersActions.updateVoiceState, (state, { userId, voiceState }) =>
|
||||
usersAdapter.updateOne(
|
||||
{
|
||||
id: userId,
|
||||
changes: {
|
||||
voiceState: {
|
||||
isConnected: voiceState.isConnected ?? prev.isConnected,
|
||||
isMuted: voiceState.isMuted ?? prev.isMuted,
|
||||
isDeafened: voiceState.isDeafened ?? prev.isDeafened,
|
||||
isSpeaking: voiceState.isSpeaking ?? prev.isSpeaking,
|
||||
isMutedByAdmin: voiceState.isMutedByAdmin ?? prev.isMutedByAdmin,
|
||||
volume: voiceState.volume ?? prev.volume,
|
||||
roomId: hasRoomId ? voiceState.roomId : prev.roomId,
|
||||
serverId: hasServerId ? voiceState.serverId : prev.serverId,
|
||||
clientInstanceId: hasClientInstanceId ? voiceState.clientInstanceId : prev.clientInstanceId
|
||||
}
|
||||
voiceState: mergeVoiceStateUpdate(state.entities[userId]?.voiceState, voiceState)
|
||||
}
|
||||
},
|
||||
state
|
||||
);
|
||||
}),
|
||||
)
|
||||
),
|
||||
on(UsersActions.updateScreenShareState, (state, { userId, screenShareState }) => {
|
||||
const prev = state.entities[userId]?.screenShareState || {
|
||||
isSharing: false
|
||||
|
||||
@@ -80,36 +80,58 @@ function formatKb(kb) {
|
||||
}
|
||||
|
||||
function summarize(entries) {
|
||||
const latestHighMemory = [...entries].reverse().find((entry) => entry.type === 'high-memory');
|
||||
const latestProcess = [...entries].reverse().find((entry) => entry.type === 'process');
|
||||
const latestStore = [...entries].reverse().find((entry) => entry.type === 'store');
|
||||
const latestComponents = [...entries].reverse().find((entry) => entry.type === 'components');
|
||||
const latestHeap = [...entries].reverse().find((entry) => entry.type === 'heap');
|
||||
const latestStore = latestHighMemory?.payload?.recentRendererSamples?.store
|
||||
?? [...entries].reverse().find((entry) => entry.type === 'store')?.payload;
|
||||
const latestComponents = latestHighMemory?.payload?.recentRendererSamples?.components
|
||||
?? [...entries].reverse().find((entry) => entry.type === 'components')?.payload;
|
||||
const latestHeap = latestHighMemory?.payload?.recentRendererSamples?.heap
|
||||
?? [...entries].reverse().find((entry) => entry.type === 'heap')?.payload;
|
||||
|
||||
if (latestProcess) {
|
||||
if (latestHighMemory?.payload?.summary) {
|
||||
const summary = latestHighMemory.payload.summary;
|
||||
|
||||
console.log(`High memory threshold crossed: ${summary.totalWorkingSetGb} GB (threshold ${summary.thresholdGb} GB)`);
|
||||
|
||||
if (Array.isArray(summary.topProcesses) && summary.topProcesses.length > 0) {
|
||||
console.log('Top processes:');
|
||||
|
||||
for (const process of summary.topProcesses.slice(0, 8)) {
|
||||
console.log(` ${process.type} (pid ${process.pid}): ${formatKb(process.workingSetKb)} (${process.sharePercent}%)`);
|
||||
}
|
||||
}
|
||||
} else if (latestProcess) {
|
||||
console.log(`Process RSS total: ${formatKb(latestProcess.payload.totalWorkingSetKb)}`);
|
||||
}
|
||||
|
||||
if (latestHeap) {
|
||||
console.log(`Renderer JS heap: ${latestHeap.payload.usedJsHeapMb ?? 'n/a'} MB`);
|
||||
console.log(`Renderer JS heap: ${latestHeap.usedJsHeapMb ?? 'n/a'} MB`);
|
||||
}
|
||||
|
||||
if (latestStore?.payload?.domains) {
|
||||
if (latestHighMemory?.payload?.mainProcessMemoryMb) {
|
||||
const mainMemory = latestHighMemory.payload.mainProcessMemoryMb;
|
||||
|
||||
console.log(`Main process heap used: ${mainMemory.heapUsedMb ?? 'n/a'} MB`);
|
||||
}
|
||||
|
||||
if (latestStore?.domains) {
|
||||
console.log('Store domains (estimated bytes):');
|
||||
|
||||
for (const [domain, bytes] of Object.entries(latestStore.payload.domains).sort((a, b) => b[1] - a[1])) {
|
||||
for (const [domain, bytes] of Object.entries(latestStore.domains).sort((a, b) => b[1] - a[1])) {
|
||||
console.log(` ${domain}: ${bytes}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (latestComponents?.payload?.domains) {
|
||||
if (latestComponents?.domains) {
|
||||
console.log('Live components by domain:');
|
||||
|
||||
for (const [domain, count] of Object.entries(latestComponents.payload.domains).sort((a, b) => b[1] - a[1])) {
|
||||
for (const [domain, count] of Object.entries(latestComponents.domains).sort((a, b) => b[1] - a[1])) {
|
||||
console.log(` ${domain}: ${count}`);
|
||||
}
|
||||
}
|
||||
|
||||
const leaks = latestComponents?.payload?.suspectedLeaks;
|
||||
const leaks = latestComponents?.suspectedLeaks;
|
||||
|
||||
if (Array.isArray(leaks) && leaks.length > 0) {
|
||||
console.log('Suspected component leaks:');
|
||||
|
||||
Reference in New Issue
Block a user