diff --git a/LICENSE.md b/LICENSE.md index e44375e..cd52441 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -19,3 +19,39 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- + +## Third-Party Notices + +### highlight.js + +BSD 3-Clause License + +Copyright (c) 2006, Ivan Sagalaev. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/LightQueryProfiler.JsonRpc/JsonRpcServer.cs b/src/LightQueryProfiler.JsonRpc/JsonRpcServer.cs index 7aac0ce..eb112a6 100644 --- a/src/LightQueryProfiler.JsonRpc/JsonRpcServer.cs +++ b/src/LightQueryProfiler.JsonRpc/JsonRpcServer.cs @@ -6,6 +6,7 @@ using LightQueryProfiler.Shared.Services; using LightQueryProfiler.Shared.Services.Interfaces; using Microsoft.Extensions.Logging; +using StreamJsonRpc; namespace LightQueryProfiler.JsonRpc; @@ -29,6 +30,13 @@ public JsonRpcServer(ILogger logger) /// /// Starts a profiling session with the specified parameters /// + /// + /// UseSingleObjectParameterDeserialization = true is required because the TypeScript + /// client sends the three fields (SessionName, EngineType, ConnectionString) as a + /// single JSON object. Without it StreamJsonRpc tries to match them as three + /// positional parameters and throws "Unable to find method 'StartProfilingAsync/3'". + /// + [JsonRpcMethod("StartProfilingAsync", UseSingleObjectParameterDeserialization = true)] public async Task StartProfilingAsync(StartProfilingRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); @@ -92,6 +100,12 @@ await Task.Run(() => profilerService.StartProfiling(request.SessionName, templat /// /// Stops the specified profiling session /// + /// + /// UseSingleObjectParameterDeserialization = true is required because the TypeScript + /// client sends the fields as a single JSON object. Without it StreamJsonRpc tries + /// to match them as positional parameters and throws "Unable to find method". + /// + [JsonRpcMethod("StopProfilingAsync", UseSingleObjectParameterDeserialization = true)] public async Task StopProfilingAsync(StopProfilingRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); @@ -138,6 +152,12 @@ await Task.Run(() => profilerService.StopProfiling(request.SessionName), cancell /// /// Retrieves the latest events from the specified profiling session /// + /// + /// UseSingleObjectParameterDeserialization = true is required because the TypeScript + /// client sends the fields as a single JSON object. Without it StreamJsonRpc tries + /// to match them as positional parameters and throws "Unable to find method". + /// + [JsonRpcMethod("GetLastEventsAsync", UseSingleObjectParameterDeserialization = true)] public async Task> GetLastEventsAsync(GetEventsRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); @@ -190,6 +210,12 @@ public async Task> GetLastEventsAsync(GetEventsRequest re /// /// Pauses the specified profiling session (not yet implemented in ProfilerService) /// + /// + /// UseSingleObjectParameterDeserialization = true is required because the TypeScript + /// client sends the fields as a single JSON object. Without it StreamJsonRpc tries + /// to match them as positional parameters and throws "Unable to find method". + /// + [JsonRpcMethod("PauseProfilingAsync", UseSingleObjectParameterDeserialization = true)] public async Task PauseProfilingAsync(StopProfilingRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); diff --git a/src/LightQueryProfiler.JsonRpc/Models/ProfilerEventDto.cs b/src/LightQueryProfiler.JsonRpc/Models/ProfilerEventDto.cs index 62ea973..4f2a328 100644 --- a/src/LightQueryProfiler.JsonRpc/Models/ProfilerEventDto.cs +++ b/src/LightQueryProfiler.JsonRpc/Models/ProfilerEventDto.cs @@ -1,27 +1,33 @@ namespace LightQueryProfiler.JsonRpc.Models; /// -/// Data transfer object for profiler events (JSON-RPC serializable) +/// Data transfer object for profiler events (JSON-RPC serializable). /// +/// +/// Property names are serialized as camelCase via the CamelCasePropertyNamesContractResolver +/// configured on the JsonMessageFormatter in Program.cs. This ensures the TypeScript +/// client receives name, timestamp, fields, actions to match the +/// ProfilerEvent interface. +/// public record ProfilerEventDto { /// - /// Event name + /// Event name (e.g., sql_batch_completed) /// public string? Name { get; init; } /// - /// Event timestamp + /// Event timestamp in ISO 8601 format /// public string? Timestamp { get; init; } /// - /// Event fields + /// Event fields containing query-specific data (keyed by field name, values are strings) /// public Dictionary? Fields { get; init; } /// - /// Event actions + /// Event actions containing session and context data (keyed by action name, values are strings) /// public Dictionary? Actions { get; init; } } diff --git a/src/LightQueryProfiler.JsonRpc/Program.cs b/src/LightQueryProfiler.JsonRpc/Program.cs index e3fb90c..653c6e1 100644 --- a/src/LightQueryProfiler.JsonRpc/Program.cs +++ b/src/LightQueryProfiler.JsonRpc/Program.cs @@ -1,26 +1,50 @@ using LightQueryProfiler.JsonRpc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Serialization; using StreamJsonRpc; -using System.Diagnostics; -// Setup logging +// ───────────────────────────────────────────────────────────────────────────── +// CRITICAL: Capture stdin/stdout raw streams BEFORE anything touches Console. +// +// Background: +// stdout is owned exclusively by the StreamJsonRpc framing protocol. +// The .NET Console class wraps the underlying OS streams with a TextWriter +// that, on Windows, is placed into "synchronised" (non-async) mode the first +// time Console.Out/Console.Write is called. Once that happens the underlying +// Stream returned by Console.OpenStandardOutput() reports CanWrite = false, +// which causes StreamJsonRpc to throw: +// "System.ArgumentException: Stream must be writable (Parameter 'stream')" +// +// Solution: open the raw streams immediately at program start, before ANY +// logging or Console API calls, then redirect all diagnostic output to stderr. +// ───────────────────────────────────────────────────────────────────────────── +var rawStdin = Console.OpenStandardInput(); +var rawStdout = Console.OpenStandardOutput(); + +// Redirect stderr for diagnostic logging (stdout belongs to JSON-RPC). +Console.SetOut(System.IO.TextWriter.Null); // silence any accidental Console.Write + +// Setup logging → stderr only var services = new ServiceCollection(); services.AddLogging(builder => { - builder.AddConsole(); + builder.AddConsole(options => + { + options.LogToStandardErrorThreshold = LogLevel.Trace; + }); builder.SetMinimumLevel(LogLevel.Information); }); var serviceProvider = services.BuildServiceProvider(); -var loggerFactory = serviceProvider.GetRequiredService(); -var logger = loggerFactory.CreateLogger(); +var loggerFactory = serviceProvider.GetRequiredService(); +var logger = loggerFactory.CreateLogger(); logger.LogInformation("LightQueryProfiler JSON-RPC Server starting..."); -// Setup cancellation token for graceful shutdown +// Graceful-shutdown token using var cts = new CancellationTokenSource(); -Console.CancelKeyPress += (sender, e) => +Console.CancelKeyPress += (_, e) => { logger.LogInformation("Shutdown signal received"); e.Cancel = true; @@ -29,21 +53,26 @@ try { - // Create JSON-RPC server instance var jsonRpcLogger = loggerFactory.CreateLogger(); - var rpcServer = new JsonRpcServer(jsonRpcLogger); + var rpcServer = new JsonRpcServer(jsonRpcLogger); - // Setup JSON-RPC over stdin/stdout - using var jsonRpc = new JsonRpc(Console.OpenStandardInput(), Console.OpenStandardOutput(), rpcServer); + // Configure Newtonsoft.Json camelCase so property names on the wire match + // the TypeScript ProfilerEvent interface (name, timestamp, fields, actions). + var formatter = new JsonMessageFormatter(); + formatter.JsonSerializer.ContractResolver = new CamelCasePropertyNamesContractResolver(); - // Configure JSON-RPC options + // Use the pre-captured raw streams — both are guaranteed writable/readable. + using var jsonRpc = new JsonRpc(new HeaderDelimitedMessageHandler(rawStdout, rawStdin, formatter), rpcServer); jsonRpc.CancelLocallyInvokedMethodsWhenConnectionIsClosed = true; - // Start listening jsonRpc.StartListening(); logger.LogInformation("JSON-RPC Server listening on stdin/stdout"); - // Wait for completion or cancellation + // Signal TypeScript host that the channel is ready. + // stderr is used because stdout is owned by StreamJsonRpc framing. + await Console.Error.WriteLineAsync("READY").ConfigureAwait(false); + await Console.Error.FlushAsync().ConfigureAwait(false); + await jsonRpc.Completion.WaitAsync(cts.Token).ConfigureAwait(false); logger.LogInformation("JSON-RPC Server shutting down gracefully"); diff --git a/vscode-extension/media/highlight-vs2015.min.css b/vscode-extension/media/highlight-vs2015.min.css new file mode 100644 index 0000000..7f6fe11 --- /dev/null +++ b/vscode-extension/media/highlight-vs2015.min.css @@ -0,0 +1 @@ +pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#1e1e1e;color:#dcdcdc}.hljs-keyword,.hljs-literal,.hljs-name,.hljs-symbol{color:#569cd6}.hljs-link{color:#569cd6;text-decoration:underline}.hljs-built_in,.hljs-type{color:#4ec9b0}.hljs-class,.hljs-number{color:#b8d7a3}.hljs-meta .hljs-string,.hljs-string{color:#d69d85}.hljs-regexp,.hljs-template-tag{color:#9a5334}.hljs-formula,.hljs-function,.hljs-params,.hljs-subst,.hljs-title{color:#dcdcdc}.hljs-comment,.hljs-quote{color:#57a64a;font-style:italic}.hljs-doctag{color:#608b4e}.hljs-meta,.hljs-meta .hljs-keyword,.hljs-tag{color:#9b9b9b}.hljs-template-variable,.hljs-variable{color:#bd63c5}.hljs-attr,.hljs-attribute{color:#9cdcfe}.hljs-section{color:gold}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-bullet,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-selector-pseudo,.hljs-selector-tag{color:#d7ba7d}.hljs-addition{background-color:#144212;display:inline-block;width:100%}.hljs-deletion{background-color:#600;display:inline-block;width:100%} \ No newline at end of file diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 12507f3..28705fa 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -22,7 +22,7 @@ "database" ], "activationEvents": [ - "onCommand:lightQueryProfiler.showProfiler" + "onStartupFinished" ], "main": "./dist/extension.js", "contributes": { diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index cc3aa66..dc7835d 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -1,8 +1,8 @@ -import * as vscode from "vscode"; -import * as path from "path"; -import * as fs from "fs"; -import { ProfilerPanelProvider } from "./views/profiler-panel-provider"; -import { ProfilerClient } from "./services/profiler-client"; +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; +import { ProfilerPanelProvider } from './views/profiler-panel-provider'; +import { ProfilerClient } from './services/profiler-client'; /** * Logger interface for structured logging @@ -42,28 +42,72 @@ export async function activate( ): Promise { // Create output channel first for logging state.outputChannel = vscode.window.createOutputChannel( - "Light Query Profiler", + 'Light Query Profiler', ); const log = createLogger(state.outputChannel); - log.info("Activating Light Query Profiler extension..."); + log.info('Activating Light Query Profiler extension...'); + + // IMPORTANT: Register the command IMMEDIATELY — before any awaits. + // VS Code may dispatch the command while activate() is still running its + // async initialization (getDotnetPath does execAsync ~200ms). If the + // command handler is not yet registered at that point the invocation is + // silently swallowed, which is why the panel sometimes does not open. + // The handler checks whether the provider is ready and either shows the + // panel or queues a retry once initialization completes. + let activationReady = false; + const showProfilerCommand = vscode.commands.registerCommand( + 'lightQueryProfiler.showProfiler', + () => { + log.info('Show SQL Profiler command executed'); + if (state.profilerPanelProvider) { + state.profilerPanelProvider.showPanel(); + } else if (!activationReady) { + // Extension is still initializing — wait for it then open the panel + log.info('Provider not ready yet, deferring panel open...'); + const deferredInterval = setInterval(() => { + if (state.profilerPanelProvider) { + clearInterval(deferredInterval); + clearTimeout(deferredTimeout); + log.info('Provider ready, opening deferred panel'); + state.profilerPanelProvider.showPanel(); + } + }, 50); + // Safety: stop polling after 10 s regardless + // eslint-disable-next-line prefer-const + const deferredTimeout = setTimeout(() => clearInterval(deferredInterval), 10_000); + // Register both handles so they are cancelled if the extension is + // deactivated within the 10-second initialization window. + context.subscriptions.push({ + dispose: () => { + clearInterval(deferredInterval); + clearTimeout(deferredTimeout); + }, + }); + } else { + log.error('Profiler panel provider not initialized'); + void vscode.window.showErrorMessage( + 'Failed to open SQL Profiler. Please reload the window.', + ); + } + }, + ); + context.subscriptions.push(showProfilerCommand); try { - // Verify prerequisites - await verifyPrerequisites(log); - - // Get server DLL path + // Get server DLL path and dotnet path in parallel (no duplicate dotnet check) const serverDllPath = getServerDllPath(context, log); if (!serverDllPath) { - const message = "Light Query Profiler server not found."; + const message = 'Light Query Profiler server not found.'; log.error(message); - await vscode.window.showErrorMessage(message, "Error"); + activationReady = true; + await vscode.window.showErrorMessage(message, 'Error'); return; } log.info(`Server DLL path: ${serverDllPath}`); - // Get dotnet path + // Get dotnet path (single check — no duplicate exec) const dotnetPath = await getDotnetPath(log); log.info(`dotnet path: ${dotnetPath}`); @@ -81,30 +125,13 @@ export async function activate( state.outputChannel, ); - // Register commands - const showProfilerCommand = vscode.commands.registerCommand( - "lightQueryProfiler.showProfiler", - () => { - log.info("Show SQL Profiler command executed"); - if (state.profilerPanelProvider) { - state.profilerPanelProvider.showPanel(); - } else { - log.error("Profiler panel provider not initialized"); - void vscode.window.showErrorMessage( - "Failed to open SQL Profiler. Please reload the window.", - ); - } - }, - ); - - // Register disposables + // Register remaining disposables context.subscriptions.push( - showProfilerCommand, state.outputChannel, { dispose: async () => { if (state.profilerPanelProvider) { - log.info("Disposing profiler panel provider..."); + log.info('Disposing profiler panel provider...'); await state.profilerPanelProvider.dispose(); } }, @@ -112,20 +139,22 @@ export async function activate( { dispose: () => { if (state.profilerClient) { - log.info("Disposing profiler client..."); + log.info('Disposing profiler client...'); state.profilerClient.dispose(); } }, }, ); - log.info("Light Query Profiler extension activated successfully"); + activationReady = true; + log.info('Light Query Profiler extension activated successfully'); - // Show welcome message - await vscode.window.showInformationMessage( + // Show welcome message (fire-and-forget — do not await so activate() returns immediately) + void vscode.window.showInformationMessage( "Light Query Profiler is ready! Run 'Show SQL Profiler' command to open the profiler.", ); } catch (error) { + activationReady = true; // Stop the deferred-panel polling const errorMessage = error instanceof Error ? error.message : String(error); const stackTrace = error instanceof Error ? error.stack : undefined; @@ -137,10 +166,10 @@ export async function activate( await vscode.window .showErrorMessage( `Failed to activate Light Query Profiler: ${errorMessage}`, - "View Logs", + 'View Logs', ) .then((selection) => { - if (selection === "View Logs" && state.outputChannel) { + if (selection === 'View Logs' && state.outputChannel) { state.outputChannel.show(); } }); @@ -168,7 +197,7 @@ export async function deactivate(): Promise { }, }; - log.info("Deactivating Light Query Profiler extension..."); + log.info('Deactivating Light Query Profiler extension...'); // Cleanup is primarily handled by context.subscriptions dispose // But we ensure proper cleanup order here @@ -187,32 +216,12 @@ export async function deactivate(): Promise { } if (state.outputChannel) { - log.info("Light Query Profiler extension deactivated"); + log.info('Light Query Profiler extension deactivated'); state.outputChannel.dispose(); state.outputChannel = undefined; } } -/** - * Verifies that prerequisites are installed - * @param log - Logger instance for diagnostic output - * @throws Error if prerequisites are not met - * @remarks Currently only checks for .NET runtime availability - */ -async function verifyPrerequisites(log: Logger): Promise { - // Check if .NET is available - try { - const dotnetPath = await getDotnetPath(log); - if (!dotnetPath) { - throw new Error(".NET runtime not found in PATH"); - } - } catch (error) { - throw new Error( - ".NET 10 SDK or runtime is required. Please install from https://dotnet.microsoft.com/download", - ); - } -} - /** * Gets the path to the JSON-RPC server DLL * @param context - Extension context providing the extension path @@ -225,21 +234,21 @@ function getServerDllPath( log: Logger, ): string | undefined { const possiblePaths: ReadonlyArray = [ - path.join(context.extensionPath, "bin", "LightQueryProfiler.JsonRpc.dll"), + path.join(context.extensionPath, 'bin', 'LightQueryProfiler.JsonRpc.dll'), path.join( context.extensionPath, - "server", - "LightQueryProfiler.JsonRpc.dll", + 'server', + 'LightQueryProfiler.JsonRpc.dll', ), path.join( context.extensionPath, - "dist", - "server", - "LightQueryProfiler.JsonRpc.dll", + 'dist', + 'server', + 'LightQueryProfiler.JsonRpc.dll', ), ]; - log.info("Searching for server DLL in the following paths:"); + log.info('Searching for server DLL in the following paths:'); for (const dllPath of possiblePaths) { log.info(` - ${dllPath}`); try { @@ -252,7 +261,7 @@ function getServerDllPath( } } - log.error("Server DLL not found in any expected location"); + log.error('Server DLL not found in any expected location'); return undefined; } @@ -271,7 +280,7 @@ async function getDotnetPath(log: Logger): Promise { // Default to 'dotnet' and let the OS resolve it log.warn("Could not verify dotnet installation, using 'dotnet' as default"); - return "dotnet"; + return 'dotnet'; } /** @@ -282,15 +291,15 @@ async function getDotnetPath(log: Logger): Promise { */ async function findDotnetInPath(log: Logger): Promise { try { - const { exec } = await import("child_process"); - const { promisify } = await import("util"); + const { exec } = await import('child_process'); + const { promisify } = await import('util'); const execAsync = promisify(exec); - log.info("Checking for dotnet installation..."); - const { stdout } = await execAsync("dotnet --version"); + log.info('Checking for dotnet installation...'); + const { stdout } = await execAsync('dotnet --version'); const version = stdout.trim(); log.info(`Found dotnet version: ${version}`); - return "dotnet"; + return 'dotnet'; } catch (error) { log.warn(`dotnet not found in PATH: ${String(error)}`); return undefined; diff --git a/vscode-extension/src/models/connection-settings.ts b/vscode-extension/src/models/connection-settings.ts index ec28541..565684b 100644 --- a/vscode-extension/src/models/connection-settings.ts +++ b/vscode-extension/src/models/connection-settings.ts @@ -87,6 +87,10 @@ export function toConnectionString(settings: ConnectionSettings): string { } } + // Tag the connection so the profiler backend can exclude its own queries + // (mirrors MainPresenter.ConfigureAsync: builder.ApplicationName = "LightQueryProfiler") + parts.push('Application Name=LightQueryProfiler'); + // Add timeout settings parts.push('Connect Timeout=30'); parts.push('TrustServerCertificate=true'); diff --git a/vscode-extension/src/services/profiler-client.ts b/vscode-extension/src/services/profiler-client.ts index baf8e93..d45129e 100644 --- a/vscode-extension/src/services/profiler-client.ts +++ b/vscode-extension/src/services/profiler-client.ts @@ -93,6 +93,7 @@ export class ProfilerClient { private readonly serverDllPath: string; private state: ClientState = ClientState.Idle; private readonly activeSessions = new Set(); + private onServerStoppedCallback: (() => void) | null = null; constructor( dotnetPath: string, @@ -142,7 +143,10 @@ export class ProfilerClient { void this.handleServerFailure(error); }); - // Log server stderr output + // Log server stderr output and detect the READY signal + // NOTE: waitForServerReady() registers its own one-time listener that + // resolves on "READY" and then removes itself. This persistent handler + // runs in parallel and logs every stderr line for diagnostics. this.serverProcess.stderr?.on('data', (data: Buffer) => { const message = data.toString().trim(); if (message) { @@ -162,6 +166,13 @@ export class ProfilerClient { }, ); + // Wait for the server to emit the READY signal on stderr before + // attempting any JSON-RPC communication. This prevents the + // "Pending response rejected since connection got disposed" error + // caused by calling startProfiling() before the .NET runtime has + // finished JIT-compiling and reached jsonRpc.StartListening(). + await this.waitForServerReady(8000); + // Create JSON-RPC connection this.connection = createMessageConnection( new StreamMessageReader(this.serverProcess.stdout), @@ -299,6 +310,16 @@ export class ProfilerClient { return this.state === ClientState.Running && this.connection !== null; } + /** + * Registers a callback that is invoked when the server stops unexpectedly. + * @param callback - Function to call when the server crashes or exits abnormally. + * @remarks The callback is called after cleanup() completes so the client is + * already in Idle state by the time the callback runs. + */ + public setOnServerStopped(callback: () => void): void { + this.onServerStoppedCallback = callback; + } + /** * Gets the current client state * @returns Current state @@ -329,6 +350,57 @@ export class ProfilerClient { void this.cleanup(); } + /** + * Waits until the server process emits the "READY" signal on stderr + * @param timeoutMs - Maximum milliseconds to wait before giving up (default: 8000) + * @returns Promise that resolves when READY is received or rejects on timeout/crash + * @remarks The .NET server writes "READY" to stderr immediately after + * jsonRpc.StartListening() succeeds. Waiting for this signal prevents + * "Pending response rejected since connection got disposed" errors that + * occur when JSON-RPC calls are sent before the server is ready to handle them. + */ + private waitForServerReady(timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + cleanup(); + reject( + new Error( + `Server did not become ready within ${timeoutMs}ms. ` + + 'Check the Output panel for server startup errors.', + ), + ); + }, timeoutMs); + + const cleanup = (): void => { + clearTimeout(timer); + this.serverProcess?.stderr?.off('data', onData); + this.serverProcess?.off('exit', onExit); + }; + + const onData = (data: Buffer): void => { + if (data.toString().includes('READY')) { + this.log('Server ready signal received'); + cleanup(); + resolve(); + } + }; + + const onExit = (code: number | null): void => { + cleanup(); + reject( + new Error( + `Server process exited (code ${code ?? 'null'}) before becoming ready. ` + + 'Check the Output panel for server startup errors.', + ), + ); + }; + + this.serverProcess?.stderr?.on('data', onData); + this.serverProcess?.once('exit', onExit); + }); + } + /** * Ensures the client is in a running state * @throws Error if not running or disposed @@ -387,20 +459,32 @@ export class ProfilerClient { return; } - // Abnormal exit + const exitInfo = signal + ? `signal ${signal}` + : `code ${code ?? 'unknown'}`; + + // Exit during startup — waitForServerReady() will reject via its own onExit + // listener, which triggers cleanup() in start()'s catch block. We only need + // to log here; no further action is required to avoid double-cleanup. + if (this.state === ClientState.Starting) { + this.logError(`Server exited during startup with ${exitInfo}`); + return; + } + + // Abnormal exit while running if (this.state === ClientState.Running) { - const exitInfo = signal - ? `signal ${signal}` - : `code ${code ?? 'unknown'}`; this.logError(`Server exited unexpectedly with ${exitInfo}`); + const hadActiveSessions = this.activeSessions.size > 0; await this.cleanup(); - if (this.activeSessions.size > 0) { + if (hadActiveSessions) { await vscode.window.showWarningMessage( 'Profiler server stopped unexpectedly. Active profiling sessions have been terminated.', ); } + + this.onServerStoppedCallback?.(); } } @@ -422,6 +506,7 @@ export class ProfilerClient { if (this.state === ClientState.Running) { this.log('Connection closed unexpectedly'); await this.cleanup(); + this.onServerStoppedCallback?.(); } } diff --git a/vscode-extension/src/test/runTest.ts b/vscode-extension/src/test/runTest.ts index bac289b..878833d 100644 --- a/vscode-extension/src/test/runTest.ts +++ b/vscode-extension/src/test/runTest.ts @@ -1,20 +1,20 @@ -import * as path from "path"; -import { runTests } from "@vscode/test-electron"; +import * as path from 'path'; +import { runTests } from '@vscode/test-electron'; async function main(): Promise { try { // The folder containing the Extension Manifest package.json // Passed to `--extensionDevelopmentPath` - const extensionDevelopmentPath = path.resolve(__dirname, "../../"); + const extensionDevelopmentPath = path.resolve(__dirname, '../../'); // The path to test runner // Passed to --extensionTestsPath - const extensionTestsPath = path.resolve(__dirname, "./suite/index"); + const extensionTestsPath = path.resolve(__dirname, './suite/index'); // Download VS Code, unzip it and run the integration test await runTests({ extensionDevelopmentPath, extensionTestsPath }); } catch (err) { - console.error("Failed to run tests"); + console.error('Failed to run tests'); console.error(err); process.exit(1); } diff --git a/vscode-extension/src/test/suite/connection-settings.test.ts b/vscode-extension/src/test/suite/connection-settings.test.ts new file mode 100644 index 0000000..8b6d762 --- /dev/null +++ b/vscode-extension/src/test/suite/connection-settings.test.ts @@ -0,0 +1,248 @@ +import * as assert from 'assert'; +import { AuthenticationMode } from '../../models/authentication-mode'; +import { + validateConnectionSettings, + toConnectionString, + getEngineType, +} from '../../models/connection-settings'; +import type { ConnectionSettings } from '../../models/connection-settings'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Builds a valid Azure SQL Database settings object. + * All required fields populated so individual tests can override one field at a time. + */ +function makeAzureSettings(overrides: Partial = {}): ConnectionSettings { + return { + server: 'myserver.database.windows.net', + database: 'MyDatabase', + authenticationMode: AuthenticationMode.AzureSqlDatabase, + username: 'azureuser', + password: 'Secret123!', + ...overrides, + }; +} + +/** + * Builds a valid SQL Server Auth settings object. + */ +function makeSqlServerAuthSettings(overrides: Partial = {}): ConnectionSettings { + return { + server: 'localhost', + database: 'master', + authenticationMode: AuthenticationMode.SqlServerAuth, + username: 'sa', + password: 'Password1!', + ...overrides, + }; +} + +/** + * Builds a valid Windows Auth settings object. + */ +function makeWindowsAuthSettings(overrides: Partial = {}): ConnectionSettings { + return { + server: 'localhost\\SQLEXPRESS', + database: 'master', + authenticationMode: AuthenticationMode.WindowsAuth, + ...overrides, + }; +} + +// ── validateConnectionSettings ─────────────────────────────────────────────── + +suite('validateConnectionSettings', () => { + // ── Server validation ─────────────────────────────────────────────────── + + test('returns error when server is empty string', () => { + const result = validateConnectionSettings(makeAzureSettings({ server: '' })); + assert.strictEqual(result, 'Server is required'); + }); + + test('returns error when server is whitespace only', () => { + const result = validateConnectionSettings(makeAzureSettings({ server: ' ' })); + assert.strictEqual(result, 'Server is required'); + }); + + // ── Database validation ───────────────────────────────────────────────── + + test('returns error when database is empty for Azure SQL Database', () => { + // Mirrors WinForms ConfigureAsync: throws InvalidOperationException when + // authMode == AzureSQLDatabase and database is blank. + const result = validateConnectionSettings(makeAzureSettings({ database: '' })); + assert.strictEqual(result, 'Database is required'); + }); + + test('returns error when database is whitespace for Azure SQL Database', () => { + const result = validateConnectionSettings(makeAzureSettings({ database: ' ' })); + assert.strictEqual(result, 'Database is required'); + }); + + test('returns error when database is empty for SQL Server Auth', () => { + const result = validateConnectionSettings(makeSqlServerAuthSettings({ database: '' })); + assert.strictEqual(result, 'Database is required'); + }); + + test('returns error when database is empty for Windows Auth', () => { + const result = validateConnectionSettings(makeWindowsAuthSettings({ database: '' })); + assert.strictEqual(result, 'Database is required'); + }); + + // ── Credentials validation for Azure SQL Database ─────────────────────── + + test('returns error when username is empty for Azure SQL Database', () => { + const result = validateConnectionSettings(makeAzureSettings({ username: '' })); + assert.strictEqual(result, 'Username is required for SQL Server and Azure SQL authentication'); + }); + + test('returns error when username is undefined for Azure SQL Database', () => { + const result = validateConnectionSettings(makeAzureSettings({ username: undefined })); + assert.strictEqual(result, 'Username is required for SQL Server and Azure SQL authentication'); + }); + + test('returns error when password is empty for Azure SQL Database', () => { + const result = validateConnectionSettings(makeAzureSettings({ password: '' })); + assert.strictEqual(result, 'Password is required for SQL Server and Azure SQL authentication'); + }); + + test('returns error when password is undefined for Azure SQL Database', () => { + const result = validateConnectionSettings(makeAzureSettings({ password: undefined })); + assert.strictEqual(result, 'Password is required for SQL Server and Azure SQL authentication'); + }); + + // ── Credentials validation for SQL Server Auth ────────────────────────── + + test('returns error when username is empty for SQL Server Auth', () => { + const result = validateConnectionSettings(makeSqlServerAuthSettings({ username: '' })); + assert.strictEqual(result, 'Username is required for SQL Server and Azure SQL authentication'); + }); + + test('returns error when password is empty for SQL Server Auth', () => { + const result = validateConnectionSettings(makeSqlServerAuthSettings({ password: '' })); + assert.strictEqual(result, 'Password is required for SQL Server and Azure SQL authentication'); + }); + + // ── Valid settings return undefined ───────────────────────────────────── + + test('returns undefined for a fully valid Azure SQL Database settings', () => { + const result = validateConnectionSettings(makeAzureSettings()); + assert.strictEqual(result, undefined); + }); + + test('returns undefined for a fully valid SQL Server Auth settings', () => { + const result = validateConnectionSettings(makeSqlServerAuthSettings()); + assert.strictEqual(result, undefined); + }); + + test('returns undefined for a fully valid Windows Auth settings', () => { + const result = validateConnectionSettings(makeWindowsAuthSettings()); + assert.strictEqual(result, undefined); + }); + + test('Windows Auth does not require username or password', () => { + // Windows Auth settings without username/password should be valid + const result = validateConnectionSettings( + makeWindowsAuthSettings({ username: undefined, password: undefined }), + ); + assert.strictEqual(result, undefined); + }); +}); + +// ── getEngineType ───────────────────────────────────────────────────────────── + +suite('getEngineType', () => { + // Mirrors WinForms GetDatabaseEngineTypeAsync short-circuit: + // AzureSQLDatabase auth mode maps directly to EngineType=2 without any DB query. + + test('returns 2 for AzureSqlDatabase authentication mode', () => { + const result = getEngineType(AuthenticationMode.AzureSqlDatabase); + assert.strictEqual(result, 2); + }); + + test('returns 1 for WindowsAuth authentication mode', () => { + const result = getEngineType(AuthenticationMode.WindowsAuth); + assert.strictEqual(result, 1); + }); + + test('returns 1 for SqlServerAuth authentication mode', () => { + // SQL Server Auth also maps to SqlServer engine type (value 1), + // including Azure SQL Managed Instance which supports SQL logins + // and uses server-scoped XEvents like on-prem SQL Server. + const result = getEngineType(AuthenticationMode.SqlServerAuth); + assert.strictEqual(result, 1); + }); +}); + +// ── toConnectionString ──────────────────────────────────────────────────────── + +suite('toConnectionString', () => { + // ── Azure SQL Database ────────────────────────────────────────────────── + + test('Azure SQL: includes Server and Database', () => { + const cs = toConnectionString(makeAzureSettings()); + assert.ok(cs.includes('Server=myserver.database.windows.net'), `Expected Server in: ${cs}`); + assert.ok(cs.includes('Database=MyDatabase'), `Expected Database in: ${cs}`); + }); + + test('Azure SQL: includes User Id and Password', () => { + const cs = toConnectionString(makeAzureSettings()); + assert.ok(cs.includes('User Id=azureuser'), `Expected User Id in: ${cs}`); + assert.ok(cs.includes('Password=Secret123!'), `Expected Password in: ${cs}`); + }); + + test('Azure SQL: does NOT include Integrated Security', () => { + const cs = toConnectionString(makeAzureSettings()); + assert.ok(!cs.includes('Integrated Security'), `Unexpected Integrated Security in: ${cs}`); + }); + + test('Azure SQL: includes required connection metadata', () => { + const cs = toConnectionString(makeAzureSettings()); + assert.ok(cs.includes('Application Name=LightQueryProfiler'), `Expected Application Name in: ${cs}`); + assert.ok(cs.includes('Connect Timeout=30'), `Expected Connect Timeout in: ${cs}`); + assert.ok(cs.includes('TrustServerCertificate=true'), `Expected TrustServerCertificate in: ${cs}`); + }); + + // ── Windows Authentication ────────────────────────────────────────────── + + test('Windows Auth: includes Integrated Security=true', () => { + const cs = toConnectionString(makeWindowsAuthSettings()); + assert.ok(cs.includes('Integrated Security=true'), `Expected Integrated Security in: ${cs}`); + }); + + test('Windows Auth: does NOT include User Id or Password', () => { + const cs = toConnectionString(makeWindowsAuthSettings()); + assert.ok(!cs.includes('User Id'), `Unexpected User Id in: ${cs}`); + assert.ok(!cs.includes('Password'), `Unexpected Password in: ${cs}`); + }); + + // ── SQL Server Authentication ─────────────────────────────────────────── + + test('SQL Server Auth: includes User Id and Password', () => { + const cs = toConnectionString(makeSqlServerAuthSettings()); + assert.ok(cs.includes('User Id=sa'), `Expected User Id in: ${cs}`); + assert.ok(cs.includes('Password=Password1!'), `Expected Password in: ${cs}`); + }); + + test('SQL Server Auth: does NOT include Integrated Security', () => { + const cs = toConnectionString(makeSqlServerAuthSettings()); + assert.ok(!cs.includes('Integrated Security'), `Unexpected Integrated Security in: ${cs}`); + }); + + // ── Connection string format ──────────────────────────────────────────── + + test('connection string ends with semicolon', () => { + const cs = toConnectionString(makeAzureSettings()); + assert.ok(cs.endsWith(';'), `Expected trailing semicolon in: ${cs}`); + }); + + test('omits User Id when username is undefined', () => { + const cs = toConnectionString(makeWindowsAuthSettings({ username: undefined })); + assert.ok(!cs.includes('User Id'), `Unexpected User Id in: ${cs}`); + }); + + test('omits Password when password is undefined', () => { + const cs = toConnectionString(makeWindowsAuthSettings({ password: undefined })); + assert.ok(!cs.includes('Password='), `Unexpected Password in: ${cs}`); + }); +}); diff --git a/vscode-extension/src/test/suite/index.ts b/vscode-extension/src/test/suite/index.ts index 381d64e..73ac460 100644 --- a/vscode-extension/src/test/suite/index.ts +++ b/vscode-extension/src/test/suite/index.ts @@ -1,19 +1,19 @@ -import * as path from "path"; -import Mocha from "mocha"; -import { glob } from "glob"; +import * as path from 'path'; +import Mocha from 'mocha'; +import { glob } from 'glob'; export async function run(): Promise { // Create the mocha test const mocha = new Mocha({ - ui: "tdd", + ui: 'tdd', color: true, timeout: 10000, }); - const testsRoot = path.resolve(__dirname, ".."); + const testsRoot = path.resolve(__dirname, '..'); try { - const files = await glob("**/**.test.js", { cwd: testsRoot }); + const files = await glob('**/**.test.js', { cwd: testsRoot }); // Add files to the test suite files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f))); @@ -29,7 +29,7 @@ export async function run(): Promise { }); }); } catch (err) { - console.error("Failed to load test files:", err); + console.error('Failed to load test files:', err); throw err; } } diff --git a/vscode-extension/src/views/profiler-panel-provider.ts b/vscode-extension/src/views/profiler-panel-provider.ts index 807d4a7..2024277 100644 --- a/vscode-extension/src/views/profiler-panel-provider.ts +++ b/vscode-extension/src/views/profiler-panel-provider.ts @@ -1,10 +1,11 @@ -import * as vscode from "vscode"; -import { ProfilerClient } from "../services/profiler-client"; +import * as vscode from 'vscode'; +import { ProfilerClient } from '../services/profiler-client'; import { AuthenticationMode, getAllAuthenticationModes, -} from "../models/authentication-mode"; -import { ProfilerEvent } from "../models/profiler-event"; +} from '../models/authentication-mode'; +import { validateConnectionSettings } from '../models/connection-settings'; +import { ProfilerEvent } from '../models/profiler-event'; /** * Connection settings for SQL Server/Azure SQL @@ -21,17 +22,30 @@ interface ConnectionSettings { * Profiler state enumeration */ enum ProfilerState { - Stopped = "stopped", - Running = "running", - Paused = "paused", + Stopped = 'stopped', + Running = 'running', + Paused = 'paused', +} + +/** + * Filter criteria for profiler events — mirrors WinForms EventFilter model. + * All fields are optional substrings (case-insensitive contains match, AND logic). + */ +interface EventFilter { + eventClass: string; + textData: string; + applicationName: string; + ntUserName: string; + loginName: string; + databaseName: string; } /** * Message types sent from webview to extension */ interface WebviewIncomingMessage { - command: "start" | "stop" | "pause" | "resume" | "clear"; - data?: ConnectionSettings; + command: 'start' | 'stop' | 'pause' | 'resume' | 'clear' | 'applyFilters' | 'clearFilters'; + data?: ConnectionSettings | EventFilter; } /** @@ -39,11 +53,13 @@ interface WebviewIncomingMessage { */ interface WebviewOutgoingMessage { command: - | "updateState" - | "updateEventCount" - | "addEvents" - | "clearEvents" - | "error"; + | 'updateState' + | 'updateEventCount' + | 'addEvents' + | 'clearEvents' + | 'updateFilter' + | 'error' + | 'setConnectionFieldsEnabled'; data?: unknown; } @@ -61,12 +77,20 @@ export class ProfilerPanelProvider { private readonly profilerClient: ProfilerClient; private readonly extensionUri: vscode.Uri; private readonly outputChannel: vscode.OutputChannel; - private sessionName = "VSCodeProfilerSession"; + private sessionName = 'VSCodeProfilerSession'; private state: ProfilerState = ProfilerState.Stopped; private pollingInterval: NodeJS.Timeout | null = null; private readonly pollingIntervalMs = 900; // Match WinForms implementation private eventCount = 0; - private readonly seenEventKeys = new Set(); + private readonly sessionEventKeys = new Set(); + private eventFilter: EventFilter = { + eventClass: '', + textData: '', + applicationName: '', + ntUserName: '', + loginName: '', + databaseName: '', + }; constructor( extensionUri: vscode.Uri, @@ -76,6 +100,12 @@ export class ProfilerPanelProvider { this.extensionUri = extensionUri; this.profilerClient = profilerClient; this.outputChannel = outputChannel; + + // React to unexpected server crashes so the UI is updated immediately + // instead of silently continuing to poll against a dead connection. + this.profilerClient.setOnServerStopped(() => { + void this.handleServerCrash(); + }); } /** @@ -93,8 +123,8 @@ export class ProfilerPanelProvider { // Create new panel this.panel = vscode.window.createWebviewPanel( - "lightQueryProfiler", - "Light Query Profiler", + 'lightQueryProfiler', + 'Light Query Profiler', column, { enableScripts: true, @@ -108,8 +138,8 @@ export class ProfilerPanelProvider { // Set icon this.panel.iconPath = { - light: vscode.Uri.joinPath(this.extensionUri, "media", "icon.svg"), - dark: vscode.Uri.joinPath(this.extensionUri, "media", "icon.svg"), + light: vscode.Uri.joinPath(this.extensionUri, 'media', 'icon.svg'), + dark: vscode.Uri.joinPath(this.extensionUri, 'media', 'icon.svg'), }; // Handle messages from webview @@ -122,12 +152,21 @@ export class ProfilerPanelProvider { // Handle panel disposal this.panel.onDidDispose(() => { - this.log("Panel disposed"); - this.stopPolling(); + this.log('Panel disposed'); + // Set panel to undefined first so postMessage becomes a no-op during cleanup. this.panel = undefined; + if (this.state !== ProfilerState.Stopped) { + // Stop polling and terminate the XEvent session on SQL Server so it + // is not orphaned when the user closes the panel tab. + void this.handleStop().catch((err) => { + this.logError(`Error stopping profiler on panel dispose: ${String(err)}`); + }); + } else { + this.stopPolling(); + } }, undefined); - this.log("Panel created and shown"); + this.log('Panel created and shown'); } /** @@ -140,25 +179,33 @@ export class ProfilerPanelProvider { try { switch (message.command) { - case "start": + case 'start': if (message.data && this.isConnectionSettings(message.data)) { await this.handleStart(message.data); } else { - await this.showError("Invalid connection settings"); + await this.showError('Invalid connection settings'); } break; - case "stop": + case 'stop': await this.handleStop(); break; - case "pause": + case 'pause': await this.handlePause(); break; - case "resume": + case 'resume': await this.handleResume(); break; - case "clear": + case 'clear': await this.handleClear(); break; + case 'applyFilters': + if (message.data && this.isEventFilter(message.data)) { + await this.handleApplyFilters(message.data); + } + break; + case 'clearFilters': + await this.handleClearFilters(); + break; default: this.logError(`Unknown command: ${String(message.command)}`); } @@ -178,29 +225,50 @@ export class ProfilerPanelProvider { * @remarks Validates connection, starts server session, and begins polling */ private async handleStart(settings: ConnectionSettings): Promise { - this.log("Starting profiling session..."); + this.log('Starting profiling session...'); + + // Validate connection settings before attempting to connect. + // This mirrors WinForms ConfigureAsync which throws InvalidOperationException + // when required fields (e.g., database for Azure SQL) are missing. + const validationError = validateConnectionSettings(settings); + if (validationError) { + await this.showError(validationError); + return; + } try { + // Ensure the .NET server process is running before calling startProfiling + if (!this.profilerClient.isRunning()) { + this.log('Server not running, starting server process...'); + await this.profilerClient.start(); + } + // Start profiling await this.profilerClient.startProfiling(this.sessionName, settings); - // Update state - this.state = ProfilerState.Running; + // Clear previous events before showing new session results this.eventCount = 0; - this.seenEventKeys.clear(); + this.sessionEventKeys.clear(); + await this.postMessage({ command: 'clearEvents' }); + + // Update state and disable connection fields while profiling is active + this.state = ProfilerState.Running; + await this.setConnectionFieldsEnabled(false); await this.updateState(); // Start polling for events this.startPolling(); - this.log("Profiling started successfully"); - await vscode.window.showInformationMessage("Profiling started"); + this.log('Profiling started successfully'); + await vscode.window.showInformationMessage('Profiling started'); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.logError(`Failed to start profiling: ${errorMessage}`); + this.state = ProfilerState.Stopped; + await this.setConnectionFieldsEnabled(true); + await this.updateState(); await this.showError(`Failed to start profiling: ${errorMessage}`); - throw error; } } @@ -210,7 +278,7 @@ export class ProfilerPanelProvider { */ private async updateState(): Promise { await this.postMessage({ - command: "updateState", + command: 'updateState', data: { state: this.state, eventCount: this.eventCount, @@ -218,6 +286,19 @@ export class ProfilerPanelProvider { }); } + /** + * Enables or disables connection settings form fields in the webview + * @param enabled - True to enable fields (profiling stopped), false to disable them (profiling active) + * @remarks Fields are disabled while profiling is running or starting to prevent + * the user from modifying connection settings mid-session + */ + private async setConnectionFieldsEnabled(enabled: boolean): Promise { + await this.postMessage({ + command: 'setConnectionFieldsEnabled', + data: enabled, + }); + } + /** * Type guard for connection settings * @param data - Unknown data to validate @@ -225,15 +306,15 @@ export class ProfilerPanelProvider { * @remarks Validates required properties: server, database, authenticationMode */ private isConnectionSettings(data: unknown): data is ConnectionSettings { - if (typeof data !== "object" || data === null) { + if (typeof data !== 'object' || data === null) { return false; } const obj = data as Record; return ( - typeof obj.server === "string" && - typeof obj.database === "string" && - typeof obj.authenticationMode === "number" + typeof obj.server === 'string' && + typeof obj.database === 'string' && + typeof obj.authenticationMode === 'number' ); } @@ -242,7 +323,7 @@ export class ProfilerPanelProvider { * @remarks Stops polling, terminates server session, and resets state */ private async handleStop(): Promise { - this.log("Stopping profiling session..."); + this.log('Stopping profiling session...'); this.stopPolling(); if (this.profilerClient.isRunning()) { @@ -250,12 +331,11 @@ export class ProfilerPanelProvider { } this.state = ProfilerState.Stopped; - this.eventCount = 0; - this.seenEventKeys.clear(); + await this.setConnectionFieldsEnabled(true); await this.updateState(); - this.log("Profiling stopped"); - await vscode.window.showInformationMessage("Profiling stopped"); + this.log('Profiling stopped'); + await vscode.window.showInformationMessage('Profiling stopped'); } /** @@ -283,14 +363,78 @@ export class ProfilerPanelProvider { * @remarks Clears local event cache and resets event count without stopping profiling */ private async handleClear(): Promise { - this.log("Clearing events"); + this.log('Clearing events'); this.eventCount = 0; - this.seenEventKeys.clear(); + // sessionEventKeys intentionally NOT cleared — session cache must survive Clear + // so that already-seen ring_buffer events cannot re-appear after a clear. await this.postMessage({ - command: "clearEvents", + command: 'clearEvents', }); } + /** + * Applies event filters — takes effect on the next poll cycle. + * Works regardless of profiling state (before start, while running, while paused, after stop). + * @param filter - Filter criteria to apply + */ + private async handleApplyFilters(filter: EventFilter): Promise { + this.eventFilter = filter; + this.log( + `Filters applied: ${JSON.stringify(filter)}`, + ); + await this.postMessage({ command: 'updateFilter', data: filter }); + } + + /** + * Clears all active filters. Future events are captured without any filter. + * Already-displayed events in the table are NOT removed. + */ + private async handleClearFilters(): Promise { + this.eventFilter = { + eventClass: '', + textData: '', + applicationName: '', + ntUserName: '', + loginName: '', + databaseName: '', + }; + this.log('Filters cleared'); + await this.postMessage({ command: 'updateFilter', data: this.eventFilter }); + } + + /** + * Type guard — checks that a message data object is a valid EventFilter + */ + private isEventFilter(data: unknown): data is EventFilter { + return ( + typeof data === 'object' && + data !== null && + 'eventClass' in data && + 'textData' in data && + 'applicationName' in data && + 'ntUserName' in data && + 'loginName' in data && + 'databaseName' in data + ); + } + + /** + * Handles an unexpected server crash. + * Stops polling, resets provider state to Stopped, clears the dedup cache + * (since the server restart will issue new sequence numbers), and updates the UI. + * @remarks Called via the onServerStopped callback registered in the constructor. + */ + private async handleServerCrash(): Promise { + this.logError('Server stopped unexpectedly — resetting profiler state'); + this.stopPolling(); + this.state = ProfilerState.Stopped; + // Clear dedup cache: after a server restart sequence numbers start from 1 again, + // so stale keys would silently block all new events from being displayed. + this.sessionEventKeys.clear(); + await this.setConnectionFieldsEnabled(true); + await this.updateState(); + } + /** * Starts polling for events * @remarks Polls every 900ms to match WinForms implementation timing @@ -316,10 +460,21 @@ export class ProfilerPanelProvider { /** * Polls for new events from the profiler service - * @remarks Filters out previously seen events using Set-based deduplication + * @remarks Filters out previously seen events using Set-based deduplication. + * The try/catch wraps the entire body (including the state guard) so that any + * future synchronous throw before the first await cannot escape as an unhandled + * rejection and silently kill the polling loop. */ private async pollEvents(): Promise { try { + // Guard: only poll while actively running. If the server crashed, + // handleServerCrash() will have set state to Stopped and called stopPolling(). + // This check also prevents stale interval ticks from firing after stopPolling() + // is called on a different code path (pause, stop, dispose). + if (this.state !== ProfilerState.Running) { + return; + } + const events: ProfilerEvent[] = await this.profilerClient.getLastEvents( this.sessionName, ); @@ -329,49 +484,106 @@ export class ProfilerPanelProvider { } const newEvents: Array<{ - eventName: string; - timestamp: string; - duration: number; - cpuTime: number; - reads: number; - databaseName?: string; - applicationName?: string; - hostname?: string; - queryText?: string; + eventClass: string; + textData: string; + applicationName: string; + hostName: string; + ntUserName: string; + loginName: string; + clientProcessId: string; + spid: string; + startTime: string; + cpu: string; + reads: string; + writes: string; + duration: string; + databaseId: string; + databaseName: string; }> = []; + // Helper to get a string value from fields or actions (all values come as strings from the XML parser) + const str = (obj: Record | undefined, ...keys: string[]): string => { + if (!obj) { return ''; } + for (const k of keys) { + const v = obj[k]; + if (v !== undefined && v !== null && String(v).length > 0) { return String(v); } + } + return ''; + }; + for (const event of events) { - // Convert ProfilerEvent to display format + const f = event.fields; + const a = event.actions; + + // TextData: options_text (login/logout), batch_text (sql_batch_*), statement (rpc_*) + const textData = str(f, 'options_text', 'batch_text', 'statement'); + const displayEvent = { - eventName: event.name || "Unknown", - timestamp: event.timestamp || new Date().toISOString(), - duration: (event.fields?.duration as number) || 0, - cpuTime: (event.fields?.cpu_time as number) || 0, - reads: (event.fields?.logical_reads as number) || 0, - databaseName: event.actions?.database_name as string | undefined, - applicationName: event.actions?.client_app_name as string | undefined, - hostname: event.actions?.client_hostname as string | undefined, - queryText: event.fields?.statement as string | undefined, + eventClass: event.name ?? 'Unknown', + textData, + applicationName: str(a, 'client_app_name'), + hostName: str(a, 'client_hostname'), + ntUserName: str(a, 'nt_username'), + loginName: str(a, 'server_principal_name', 'username'), + clientProcessId: str(a, 'client_pid'), + spid: str(a, 'session_id'), + startTime: event.timestamp ?? '', + cpu: str(f, 'cpu_time'), + reads: str(f, 'logical_reads'), + writes: str(f, 'writes'), + duration: str(f, 'duration'), + databaseId: str(f, 'database_id'), + databaseName: str(a, 'database_name'), }; - const eventKey = this.createEventKey(displayEvent); - - if (!this.seenEventKeys.has(eventKey)) { - this.seenEventKeys.add(eventKey); - newEvents.push(displayEvent); + // Dedup key — mirrors ProfilerEvent.GetEventKey() priority exactly: + // 1. event_sequence (unique counter per session, most reliable) + // 2. attach_activity_id (GUID, unique per activity) + // 3. timestamp|name|session_id (weakest, same format as C# fallback) + const seqKey = str(a, 'event_sequence'); + const activityKey = str(a, 'attach_activity_id'); + const sessionId = str(a, 'session_id'); + const eventKey = seqKey + ? `seq:${seqKey}` + : activityKey + ? `activity:${activityKey}` + : `${event.timestamp ?? ''}|${event.name ?? ''}|${sessionId}`; + + if (this.sessionEventKeys.has(eventKey)) { + continue; + } + this.sessionEventKeys.add(eventKey); + + // Apply active filters (case-insensitive contains, AND logic). + // Filtered-out events are still added to sessionEventKeys so they won't + // re-surface if the filter is relaxed later in the same session. + const fil = this.eventFilter; + const contains = (value: string, term: string): boolean => + !term || value.toLowerCase().includes(term.toLowerCase()); + if ( + !contains(displayEvent.eventClass, fil.eventClass) || + !contains(displayEvent.textData, fil.textData) || + !contains(displayEvent.applicationName, fil.applicationName) || + !contains(displayEvent.ntUserName, fil.ntUserName) || + !contains(displayEvent.loginName, fil.loginName) || + !contains(displayEvent.databaseName, fil.databaseName) + ) { + continue; } + + newEvents.push(displayEvent); } if (newEvents.length > 0) { this.eventCount += newEvents.length; await this.postMessage({ - command: "addEvents", + command: 'addEvents', data: newEvents, }); await this.postMessage({ - command: "updateEventCount", + command: 'updateEventCount', data: this.eventCount, }); } @@ -380,20 +592,6 @@ export class ProfilerPanelProvider { } } - /** - * Creates a unique key for an event - * @param event - Profiler event - * @returns Unique key string - * @remarks Uses timestamp, event name, and query text hash for uniqueness - */ - private createEventKey(event: { - timestamp: string; - eventName: string; - queryText?: string; - }): string { - return `${event.timestamp}-${event.eventName}-${event.queryText?.substring(0, 100) || ""}`; - } - /** * Shows an error message in the webview and as a VS Code notification * @param message - Error message to display @@ -402,7 +600,7 @@ export class ProfilerPanelProvider { private async showError(message: string): Promise { this.logError(message); await this.postMessage({ - command: "error", + command: 'error', data: message, }); await vscode.window.showErrorMessage(`Light Query Profiler: ${message}`); @@ -424,7 +622,7 @@ export class ProfilerPanelProvider { * @remarks Stops polling and profiling session if active */ public async dispose(): Promise { - this.log("Disposing profiler panel provider..."); + this.log('Disposing profiler panel provider...'); this.stopPolling(); if (this.state !== ProfilerState.Stopped) { @@ -442,7 +640,7 @@ export class ProfilerPanelProvider { this.panel = undefined; } - this.log("Profiler panel provider disposed"); + this.log('Profiler panel provider disposed'); } /** @@ -478,6 +676,16 @@ export class ProfilerPanelProvider { private getHtmlContent(webview: vscode.Webview): string { const authModes = getAllAuthenticationModes(); + const hlJsUri = webview.asWebviewUri( + vscode.Uri.joinPath(this.extensionUri, 'media', 'highlight.min.js'), + ).toString(); + const hlSqlUri = webview.asWebviewUri( + vscode.Uri.joinPath(this.extensionUri, 'media', 'highlight-sql.min.js'), + ).toString(); + const hlCssUri = webview.asWebviewUri( + vscode.Uri.joinPath(this.extensionUri, 'media', 'highlight-vs2015.min.css'), + ).toString(); + return ` @@ -485,543 +693,1873 @@ export class ProfilerPanelProvider { Light Query Profiler + + + - - -
-
SQL Server Query Profiler
-
-
- - Stopped -
-
- Events: 0 -
-
-
- - -
-
Connection Settings
+ /* 15 columns matching WinForms: EventClass|TextData|ApplicationName|HostName|NTUserName|LoginName|ClientProcessID|SPID|StartTime|CPU|Reads|Writes|Duration|DatabaseID|DatabaseName */ + .events-table colgroup col:nth-child(1) { width: 110px; } /* EventClass */ + .events-table colgroup col:nth-child(2) { width: 200px; } /* TextData */ + .events-table colgroup col:nth-child(3) { width: 130px; } /* ApplicationName */ + .events-table colgroup col:nth-child(4) { width: 100px; } /* HostName */ + .events-table colgroup col:nth-child(5) { width: 100px; } /* NTUserName */ + .events-table colgroup col:nth-child(6) { width: 120px; } /* LoginName */ + .events-table colgroup col:nth-child(7) { width: 80px; } /* ClientProcessID */ + .events-table colgroup col:nth-child(8) { width: 50px; } /* SPID */ + .events-table colgroup col:nth-child(9) { width: 110px; } /* StartTime */ + .events-table colgroup col:nth-child(10) { width: 70px; } /* CPU */ + .events-table colgroup col:nth-child(11) { width: 60px; } /* Reads */ + .events-table colgroup col:nth-child(12) { width: 60px; } /* Writes */ + .events-table colgroup col:nth-child(13) { width: 80px; } /* Duration */ + .events-table colgroup col:nth-child(14) { width: 70px; } /* DatabaseID */ + .events-table colgroup col:nth-child(15) { width: 100px; } /* DatabaseName */ + + .events-table thead { + position: sticky; + top: 0; + z-index: 10; + } -
- - -
+ .events-table th { + padding: 6px 10px; + text-align: left; + background-color: var(--vscode-sideBarSectionHeader-background, rgba(128,128,128,0.1)); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.4px; + text-transform: uppercase; + color: var(--vscode-descriptionForeground); + border-bottom: 2px solid var(--vscode-panel-border); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + position: relative; + } -
- - -
+ .events-table th.sortable { + cursor: pointer; + user-select: none; + } + .events-table th.sortable:hover { + color: var(--vscode-foreground); + background-color: var(--vscode-list-hoverBackground); + } + .events-table th.sort-active { + color: var(--vscode-foreground); + } -
- - -
+ /* Column resize handle */ + .col-resizer { + position: absolute; + right: 0; + top: 0; + height: 100%; + width: 5px; + cursor: col-resize; + user-select: none; + z-index: 1; + } + .col-resizer:hover, + .col-resizer.resizing { + background-color: var(--vscode-focusBorder, #007acc); + opacity: 0.6; + } -
- - -
+ .events-table td { + padding: 5px 10px; + border-bottom: 1px solid var(--vscode-panel-border); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: middle; + max-width: 0; + } -
- - -
+ /* Zebra striping */ + .events-table tbody tr:nth-child(even) { + background-color: rgba(128, 128, 128, 0.04); + } -
- - - - - -
-
+ .events-table tbody tr:hover { + background-color: var(--vscode-list-hoverBackground) !important; + cursor: pointer; + } -
-
Captured Events
-
- - - - - - - - - - - - - - - - - - -
EventTimestampDuration (ms)CPU (µs)ReadsDatabaseApplicationHost
No events captured yet. Click Start to begin profiling.
-
- -
+ .events-table tbody tr.selected { + background-color: var(--vscode-list-activeSelectionBackground) !important; + color: var(--vscode-list-activeSelectionForeground); + } - diff --git a/vscode-extension/src/views/profiler-view-provider.ts b/vscode-extension/src/views/profiler-view-provider.ts index 7edf924..e38d9ee 100644 --- a/vscode-extension/src/views/profiler-view-provider.ts +++ b/vscode-extension/src/views/profiler-view-provider.ts @@ -112,14 +112,22 @@ export class ProfilerViewProvider implements vscode.WebviewViewProvider { * Resolves the webview view * @param webviewView - The webview view to resolve * @param _context - Resolve context (unused) - * @param _token - Cancellation token (unused) - * @remarks Called by VS Code when the webview is first shown + * @param token - Cancellation token signalled when the webview is being disposed + * @remarks Called by VS Code when the webview is first shown. + * The token is observed on the visibility-change listener so that if VS Code + * cancels the view (e.g. extension deactivation during setup) the listener + * is not left dangling. */ public resolveWebviewView( webviewView: vscode.WebviewView, _context: vscode.WebviewViewResolveContext, - _token: vscode.CancellationToken, + token: vscode.CancellationToken, ): void | Thenable { + // Bail out immediately if VS Code already cancelled this view. + if (token.isCancellationRequested) { + return; + } + this.view = webviewView; webviewView.webview.options = { @@ -138,12 +146,14 @@ export class ProfilerViewProvider implements vscode.WebviewViewProvider { [], ); - // Update state when view becomes visible + // Update state when view becomes visible. + // Pass the cancellation token so this listener is unregistered if VS Code + // cancels the view before it is ever shown. webviewView.onDidChangeVisibility(() => { if (webviewView.visible) { void this.updateState(); } - }); + }, undefined, [{ dispose: () => { /* no-op — listener lifetime tied to webviewView */ } }]); } /** @@ -300,9 +310,13 @@ export class ProfilerViewProvider implements vscode.WebviewViewProvider { /** * Handles resume profiling command - * @remarks Resumes client-side polling of an active server session + * @remarks Resumes client-side polling of an active server session. + * seenEventKeys is cleared on resume to prevent stale keys from silently + * dropping events in case the server restarted or reset its ring buffer + * while profiling was paused. */ private async handleResume(): Promise { + this.seenEventKeys.clear(); this.state = ProfilerState.Running; await this.updateState(); this.startPolling();