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 1 commit
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
425 changes: 425 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 diagnostic_server_loop () {
lastScheduledTimeoutId = undefined;
if (loaderHelpers.is_runtime_running()) {
try {
runtimeHelpers.mono_background_exec();// give GC chance to run
runtimeHelpers.mono_wasm_ds_exec();
schedule_diagnostic_server_loop(100);
} catch (ex) {
loaderHelpers.mono_exit(1, ex);
}
}
}

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

export class DiagConnectionBase {
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 IDiagConnection {
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 IDiagSession {
session_id:SessionId;
store(message: Uint8Array): number;
sendCommand(message:Uint8Array):void;
}

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

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

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
}));
}
148 changes: 148 additions & 0 deletions src/mono/browser/runtime/diagnostics/diag-js.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// 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 { DiagConnectionBase, downloadBlob, fnClientProvider, IDiagClient, IDiagConnection, IDiagSession, schedule_diagnostic_server_loop, 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<IDiagClient>;
let fromScenarioNameOnce = false;

// Only the last which sent advert is receiving commands for all sessions
export let serverSession:DiagSession|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 DiagSession extends DiagConnectionBase implements IDiagConnection, IDiagSession {
public session_id: SessionId = undefined as any;
public diagClient?: IDiagClient;
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 connect_new_client () {
this.diagClient = await nextJsClient.promise;
cleanup_client();
const firstCommand = this.diagClient.commandOnAdvertise();
this.respond(firstCommand);
}

// this is message from the diagnostic server, which is Mono VM in this browser
send (message:Uint8Array):number {
schedule_diagnostic_server_loop();
if (advert1.every((v, i) => v === message[i])) {
// eslint-disable-next-line @typescript-eslint/no-this-alias
serverSession = this;
this.connect_new_client();
} else if (dotnet_IPC_V1.every((v, i) => v === message[i]) && message[16] == CommandSetId.Server) {
if (message[17] == ServerCommandId.OK) {
if (message.byteLength === 28) {
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);
const sessionId = [sessionIDHi, sessionIDLo] as SessionId;
this.session_id = sessionId;
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);
schedule_diagnostic_server_loop();
}

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 cleanup_client () {
nextJsClient = loaderHelpers.createPromiseController<IDiagClient>();
}

export function setup_js_client (client:IDiagClient) {
nextJsClient.promise_control.resolve(client);
}

export function createDiagConnectionJs (socket_handle:number, scenarioName:string):DiagSession {
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 DiagSession(socket_handle);
}
61 changes: 61 additions & 0 deletions src/mono/browser/runtime/diagnostics/diag-ws.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

import { IDiagConnection, DiagConnectionBase, diagnostic_server_loop, schedule_diagnostic_server_loop } from "./common";

export function createDiagConnectionWs (socket_handle:number, url:string):IDiagConnection {
return new DiagConnectionWS(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 DiagConnectionWS extends DiagConnectionBase implements IDiagConnection {
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);
diagnostic_server_loop();
};
ws.addEventListener("open", () => {
for (const data of this.messagesToSend) {
ws.send(data);
}
this.messagesToSend = [];
diagnostic_server_loop();
}, { once: true });
ws.addEventListener("message", onMessage);
ws.addEventListener("error", () => {
ws.removeEventListener("message", onMessage);
}, { once: true });
}

send (message:Uint8Array):number {
schedule_diagnostic_server_loop();
// 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 {
schedule_diagnostic_server_loop();
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 { IDiagSession } from "./common";
import { Module } from "./globals";
import { serverSession, setup_js_client } from "./diag-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: IDiagSession): void {
// stop tracing after period of monitoring
Module.safeSetTimeout(() => {
session.sendCommand(commandStopTracing(session.session_id));
}, 1000 * (options?.durationSeconds ?? 60));
}
setup_js_client({
onClosePromise:onClosePromise.promise_control,
skipDownload:options.skipDownload,
commandOnAdvertise:() => commandCounters(options.intervalSeconds || 1, options.extraProviders || []),
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, setup_js_client } from "./diag-js";
import { IDiagSession } 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: IDiagSession): void {
// stop tracing after period of monitoring
Module.safeSetTimeout(() => {
session.sendCommand(commandStopTracing(session.session_id));
}, 1000 * (options?.durationSeconds ?? 60));
}

setup_js_client({
onClosePromise:onClosePromise.promise_control,
skipDownload:options.skipDownload,
commandOnAdvertise: () => commandSampleProfiler(options.extraProviders || []),
onSessionStart,
});
return onClosePromise.promise;
}
Loading
Loading