Skip to content

[browser] event pipe - JavaScript part #110818

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Apr 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
447 changes: 447 additions & 0 deletions src/mono/browser/runtime/diagnostics/client-commands.ts

Large diffs are not rendered by default.

103 changes: 103 additions & 0 deletions src/mono/browser/runtime/diagnostics/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

import type { VoidPtr } from "../types/emscripten";
import type { PromiseController } from "../types/internal";

import { runtimeHelpers } from "./globals";
import { loaderHelpers, Module } from "./globals";
import { mono_log_info } from "./logging";

let lastScheduledTimeoutId: any = undefined;

// run another cycle of the event loop, which is EP threads on MT runtime
export function diagnosticServerEventLoop () {
lastScheduledTimeoutId = undefined;
if (loaderHelpers.is_runtime_running()) {
try {
runtimeHelpers.mono_background_exec();// give GC chance to run
runtimeHelpers.mono_wasm_ds_exec();
scheduleDiagnosticServerEventLoop(100);
} catch (ex) {
loaderHelpers.mono_exit(1, ex);
}
}
}

export function scheduleDiagnosticServerEventLoop (delay = 0):void {
if (!lastScheduledTimeoutId || delay === 0) {
lastScheduledTimeoutId = Module.safeSetTimeout(diagnosticServerEventLoop, delay);
}
}

export class DiagnosticConnectionBase {
protected messagesToSend: Uint8Array[] = [];
protected messagesReceived: Uint8Array[] = [];
constructor (public client_socket:number) {
}

store (message:Uint8Array):number {
this.messagesToSend.push(message);
return message.byteLength;
}

poll ():number {
return this.messagesReceived.length;
}

recv (buffer:VoidPtr, bytes_to_read:number):number {
if (this.messagesReceived.length === 0) {
return 0;
}
const message = this.messagesReceived[0]!;
const bytes_read = Math.min(message.length, bytes_to_read);
Module.HEAPU8.set(message.subarray(0, bytes_read), buffer as any);
if (bytes_read === message.length) {
this.messagesReceived.shift();
} else {
this.messagesReceived[0] = message.subarray(bytes_read);
}
return bytes_read;
}
}

export interface IDiagnosticConnection {
send (message: Uint8Array):number ;
poll ():number ;
recv (buffer:VoidPtr, bytes_to_read:number):number ;
close ():number ;
}

// [hi,lo]
export type SessionId=[number, number];

export interface IDiagnosticSession {
session_id:SessionId;
store(message: Uint8Array): number;
sendCommand(message:Uint8Array):void;
}

export interface IDiagnosticClient {
skipDownload?:boolean;
onClosePromise:PromiseController<Uint8Array[]>;
commandOnAdvertise():Uint8Array;
onSessionStart?(session:IDiagnosticSession):void;
onData?(session:IDiagnosticSession, message:Uint8Array):void;
onClose?(messages:Uint8Array[]):void;
onError?(session:IDiagnosticSession, message:Uint8Array):void;
}

export type fnClientProvider = (scenarioName:string) => IDiagnosticClient;

export function downloadBlob (messages:Uint8Array[]) {
const blob = new Blob(messages, { type: "application/octet-stream" });
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.download = "trace." + (new Date()).valueOf() + ".nettrace";
mono_log_info(`Downloading trace ${link.download} - ${blob.size} bytes`);
link.href = blobUrl;
document.body.appendChild(link);
link.dispatchEvent(new MouseEvent("click", {
bubbles: true, cancelable: true, view: window
}));
}
161 changes: 161 additions & 0 deletions src/mono/browser/runtime/diagnostics/diagnostics-js.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

import { advert1, CommandSetId, dotnet_IPC_V1, ServerCommandId } from "./client-commands";
import { DiagnosticConnectionBase, downloadBlob, fnClientProvider, IDiagnosticClient, IDiagnosticConnection, IDiagnosticSession, scheduleDiagnosticServerEventLoop, SessionId } from "./common";
import { PromiseAndController } from "../types/internal";
import { loaderHelpers } from "./globals";
import { mono_log_warn } from "./logging";
import { collectCpuSamples } from "./dotnet-cpu-profiler";
import { collectPerfCounters } from "./dotnet-counters";
import { collectGcDump } from "./dotnet-gcdump";

//let diagClient:IDiagClient|undefined = undefined as any;
//let server:DiagServer = undefined as any;

// configure your application
// .withEnvironmentVariable("DOTNET_DiagnosticPorts", "download:gcdump")
// or implement function globalThis.dotnetDiagnosticClient with IDiagClient interface

let nextJsClient:PromiseAndController<IDiagnosticClient>;
let fromScenarioNameOnce = false;

// Only the last which sent advert is receiving commands for all sessions
export let serverSession:DiagnosticSession|undefined = undefined;

// singleton wrapping the protocol with the diagnostic server in the Mono VM
// there could be multiple connection at the same time.
// DS:advert ->1
// 1<- DC1: command to start tracing session
// DS:OK, session ID ->1
// DS:advert ->2
// DS:events ->1
// DS:events ->1
// DS:events ->1
// DS:events ->1
// 2<- DC1: command to stop tracing session
// DS:close ->1

class DiagnosticSession extends DiagnosticConnectionBase implements IDiagnosticConnection, IDiagnosticSession {
public session_id: SessionId = undefined as any;
public diagClient?: IDiagnosticClient;
public stopDelayedAfterLastMessage:number|undefined = undefined;
public resumedRuntime = false;

constructor (public client_socket:number) {
super(client_socket);
}

sendCommand (message: Uint8Array): void {
if (!serverSession) {
mono_log_warn("no server yet");
return;
}
serverSession.respond(message);
}

async connectNewClient () {
this.diagClient = await nextJsClient.promise;
cleanupClient();
const firstCommand = this.diagClient.commandOnAdvertise();
this.respond(firstCommand);
}

is_advert_message (message:Uint8Array):boolean {
return advert1.every((v, i) => v === message[i]);
}

is_response_message (message:Uint8Array):boolean {
return dotnet_IPC_V1.every((v, i) => v === message[i]) && message[16] == CommandSetId.Server;
}

is_response_ok_with_session (message:Uint8Array):boolean {
return message.byteLength === 28 && message[17] == ServerCommandId.OK;
}

parse_session_id (message:Uint8Array):SessionId {
const view = message.subarray(20, 28);
const sessionIDLo = view[0] | (view[1] << 8) | (view[2] << 16) | (view[3] << 24);
const sessionIDHi = view[4] | (view[5] << 8) | (view[6] << 16) | (view[7] << 24);
return [sessionIDHi, sessionIDLo] as SessionId;
}

// this is message from the diagnostic server, which is Mono VM in this browser
send (message:Uint8Array):number {
scheduleDiagnosticServerEventLoop();
if (this.is_advert_message(message)) {
// eslint-disable-next-line @typescript-eslint/no-this-alias
serverSession = this;
this.connectNewClient();
} else if (this.is_response_message(message)) {
if (this.is_response_ok_with_session(message)) {
this.session_id = this.parse_session_id(message);
if (this.diagClient?.onSessionStart) {
this.diagClient.onSessionStart(this);
}
} else {
if (this.diagClient?.onError) {
this.diagClient.onError(this, message);
} else {
mono_log_warn("Diagnostic session " + this.session_id + " error : " + message.toString());
}
}
} else {
if (this.diagClient?.onData)
this.diagClient.onData(this, message);
else {
this.store(message);
}
}

return message.length;
}

// this is message to the diagnostic server, which is Mono VM in this browser
respond (message:Uint8Array) : void {
this.messagesReceived.push(message);
scheduleDiagnosticServerEventLoop();
}

close (): number {
if (this.diagClient?.onClose) {
this.diagClient.onClose(this.messagesToSend);
}
if (this.messagesToSend.length === 0) {
return 0;
}
if (this.diagClient && !this.diagClient.skipDownload) {
downloadBlob(this.messagesToSend);
}
this.messagesToSend = [];
return 0;
}
}

export function cleanupClient () {
nextJsClient = loaderHelpers.createPromiseController<IDiagnosticClient>();
}

export function setupJsClient (client:IDiagnosticClient) {
nextJsClient.promise_control.resolve(client);
}

export function createDiagConnectionJs (socket_handle:number, scenarioName:string):DiagnosticSession {
if (!fromScenarioNameOnce) {
fromScenarioNameOnce = true;
if (scenarioName.startsWith("js://gcdump")) {
collectGcDump({});
}
if (scenarioName.startsWith("js://counters")) {
collectPerfCounters({});
}
if (scenarioName.startsWith("js://cpu-samples")) {
collectCpuSamples({});
}
const dotnetDiagnosticClient:fnClientProvider = (globalThis as any).dotnetDiagnosticClient;
if (typeof dotnetDiagnosticClient === "function" ) {
nextJsClient.promise_control.resolve(dotnetDiagnosticClient(scenarioName));
}
}
return new DiagnosticSession(socket_handle);
}
63 changes: 63 additions & 0 deletions src/mono/browser/runtime/diagnostics/diagnostics-ws.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

import { IDiagnosticConnection, DiagnosticConnectionBase, diagnosticServerEventLoop, scheduleDiagnosticServerEventLoop } from "./common";
import { mono_log_warn } from "./logging";

export function createDiagConnectionWs (socket_handle:number, url:string):IDiagnosticConnection {
return new DiagnosticConnectionWS(socket_handle, url);
}

// this is used together with `dotnet-dsrouter` which will create IPC pipe on your local machine
// 1. run `dotnet-dsrouter server-websocket` this will print process ID and websocket URL
// 2. configure your wasm dotnet application `.withEnvironmentVariable("DOTNET_DiagnosticPorts", "ws://127.0.0.1:8088/diagnostics")`
// 3. run your wasm application
// 4. run `dotnet-gcdump -p <process ID>` or `dotnet-trace collect -p <process ID>`
class DiagnosticConnectionWS extends DiagnosticConnectionBase implements IDiagnosticConnection {
private ws: WebSocket;

constructor (client_socket:number, url:string) {
super(client_socket);
const ws = this.ws = new WebSocket(url);
const onMessage = async (evt:MessageEvent<Blob>) => {
const buffer = await evt.data.arrayBuffer();
const message = new Uint8Array(buffer);
this.messagesReceived.push(message);
diagnosticServerEventLoop();
};
ws.addEventListener("open", () => {
for (const data of this.messagesToSend) {
ws.send(data);
}
this.messagesToSend = [];
diagnosticServerEventLoop();
}, { once: true });
ws.addEventListener("message", onMessage);
ws.addEventListener("error", () => {
mono_log_warn("Diagnostic server WebSocket connection was closed unexpectedly.");
ws.removeEventListener("message", onMessage);
}, { once: true });
}

send (message:Uint8Array):number {
scheduleDiagnosticServerEventLoop();
// copy the message
if (this.ws!.readyState == WebSocket.CLOSED) {
return -1;
}
if (this.ws!.readyState == WebSocket.CONNECTING) {
return super.store(message);
}

this.ws!.send(message);

return message.length;
}

close ():number {
scheduleDiagnosticServerEventLoop();
this.ws.close();
return 0;
}
}

32 changes: 32 additions & 0 deletions src/mono/browser/runtime/diagnostics/dotnet-counters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

import type { DiagnosticCommandOptions } from "../types";

import { commandStopTracing, commandCounters } from "./client-commands";
import { IDiagnosticSession } from "./common";
import { Module } from "./globals";
import { serverSession, setupJsClient } from "./diagnostics-js";
import { loaderHelpers } from "./globals";

export function collectPerfCounters (options?:DiagnosticCommandOptions):Promise<Uint8Array[]> {
if (!options) options = {};
if (!serverSession) {
throw new Error("No active JS diagnostic session");
}

const onClosePromise = loaderHelpers.createPromiseController<Uint8Array[]>();
function onSessionStart (session: IDiagnosticSession): void {
// stop tracing after period of monitoring
Module.safeSetTimeout(() => {
session.sendCommand(commandStopTracing(session.session_id));
}, 1000 * (options?.durationSeconds ?? 60));
}
setupJsClient({
onClosePromise:onClosePromise.promise_control,
skipDownload:options.skipDownload,
commandOnAdvertise:() => commandCounters(options),
onSessionStart,
});
return onClosePromise.promise;
}
35 changes: 35 additions & 0 deletions src/mono/browser/runtime/diagnostics/dotnet-cpu-profiler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

import type { DiagnosticCommandOptions } from "../types";

import { commandStopTracing, commandSampleProfiler } from "./client-commands";
import { loaderHelpers, Module, runtimeHelpers } from "./globals";
import { serverSession, setupJsClient } from "./diagnostics-js";
import { IDiagnosticSession } from "./common";

export function collectCpuSamples (options?:DiagnosticCommandOptions):Promise<Uint8Array[]> {
if (!options) options = {};
if (!serverSession) {
throw new Error("No active JS diagnostic session");
}
if (!runtimeHelpers.config.environmentVariables!["DOTNET_WasmPerfInstrumentation"]) {
throw new Error("method instrumentation is not enabled, please enable it with WasmPerfInstrumentation MSBuild property");
}

const onClosePromise = loaderHelpers.createPromiseController<Uint8Array[]>();
function onSessionStart (session: IDiagnosticSession): void {
// stop tracing after period of monitoring
Module.safeSetTimeout(() => {
session.sendCommand(commandStopTracing(session.session_id));
}, 1000 * (options?.durationSeconds ?? 60));
}

setupJsClient({
onClosePromise:onClosePromise.promise_control,
skipDownload:options.skipDownload,
commandOnAdvertise: () => commandSampleProfiler(options),
onSessionStart,
});
return onClosePromise.promise;
}
Loading
Loading