Skip to content
9 changes: 8 additions & 1 deletion src/panels/minecraft-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Disposable, Webview, WebviewPanel, window, Uri, ViewColumn } from 'vsco
import { EventEmitter } from 'stream';
import { getUri } from '../utilities/getUri';
import { getNonce } from '../utilities/getNonce';
import { DebuggerRequestHandler } from '../requests/debugger-request-handler';
import { StatData, StatsListener, StatsProvider } from '../stats/stats-provider';

export class MinecraftDiagnosticsPanel {
Expand All @@ -14,16 +15,19 @@ export class MinecraftDiagnosticsPanel {
private _statsTracker: StatsProvider;
private _statsCallback: StatsListener | undefined = undefined;
private _eventEmitter: EventEmitter;
private _debuggerRequestHandler: DebuggerRequestHandler;

private constructor(
panel: WebviewPanel,
extensionUri: Uri,
statsTracker: StatsProvider,
eventEmitter: EventEmitter,
debuggerRequestHandler: DebuggerRequestHandler,
) {
this._panel = panel;
this._statsTracker = statsTracker;
this._eventEmitter = eventEmitter;
this._debuggerRequestHandler = debuggerRequestHandler;

// Set an event listener to listen for when the panel is disposed (i.e. when the user closes
// the panel or when the panel is closed programmatically)
Expand Down Expand Up @@ -67,6 +71,9 @@ export class MinecraftDiagnosticsPanel {
this._eventEmitter.emit('run-minecraft-command', message.command);
}
break;
case 'debugger-request':
this._debuggerRequestHandler.handleDebuggerRequest(message.request, message.args);
break;
default:
console.error('Unknown message type:', message.type);
break;
Expand Down Expand Up @@ -136,7 +143,7 @@ export class MinecraftDiagnosticsPanel {
}
);
MinecraftDiagnosticsPanel.activeDiagnosticsPanels.push(
new MinecraftDiagnosticsPanel(panel, extensionUri, statsTracker, eventEmitter),
new MinecraftDiagnosticsPanel(panel, extensionUri, statsTracker, eventEmitter, new DebuggerRequestHandler(panel.webview)),
);
}
}
Expand Down
85 changes: 85 additions & 0 deletions src/requests/debugger-request-handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright (C) Microsoft Corporation. All rights reserved.

import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';

vi.mock('vscode', () => ({
debug: {
activeDebugSession: undefined,
},
}));

import * as vscode from 'vscode';
import { DebuggerRequestHandler } from './debugger-request-handler';

describe('DebuggerRequestHandler', () => {
let handler: DebuggerRequestHandler;
let postMessage: Mock;

beforeEach(() => {
postMessage = vi.fn();
const webview = { postMessage } as unknown as vscode.Webview;
handler = new DebuggerRequestHandler(webview);
(vscode.debug as { activeDebugSession?: vscode.DebugSession }).activeDebugSession = undefined;
});

describe('handleDebuggerRequest', () => {
it('should post error result when no active debug session exists', async () => {
const request = 'test-request';

await handler.handleDebuggerRequest(request);

expect(postMessage).toHaveBeenCalledTimes(1);
expect(postMessage).toHaveBeenNthCalledWith(1, {
type: 'debugger-request-result',
request,
status: 'error',
error: 'No active debug session',
});
});

it('should post success result when custom request resolves', async () => {
const request = 'test-request';
const args = { value: 1 };
const responsePayload = { ok: true };
const customRequest = vi.fn().mockResolvedValue(responsePayload);
(vscode.debug as { activeDebugSession?: vscode.DebugSession }).activeDebugSession = {
customRequest,
} as unknown as vscode.DebugSession;

await handler.handleDebuggerRequest(request, args);

expect(customRequest).toHaveBeenCalledTimes(1);
expect(customRequest).toHaveBeenNthCalledWith(1, 'debugger-request', {
request,
args,
});
expect(postMessage).toHaveBeenCalledTimes(1);
expect(postMessage).toHaveBeenNthCalledWith(1, {
type: 'debugger-request-result',
request,
status: 'ok',
response: responsePayload,
});
});

it('should post error result when custom request rejects', async () => {
const request = 'test-request';
const rejection = new Error('Denied');
const customRequest = vi.fn().mockRejectedValue(rejection);
(vscode.debug as { activeDebugSession?: vscode.DebugSession }).activeDebugSession = {
customRequest,
} as unknown as vscode.DebugSession;

await handler.handleDebuggerRequest(request, { value: 2 });

expect(customRequest).toHaveBeenCalledTimes(1);
expect(postMessage).toHaveBeenCalledTimes(1);
expect(postMessage).toHaveBeenNthCalledWith(1, {
type: 'debugger-request-result',
request,
status: 'error',
error: 'Denied',
});
});
});
});
60 changes: 60 additions & 0 deletions src/requests/debugger-request-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright (C) Microsoft Corporation. All rights reserved.

import * as vscode from 'vscode';
import { DebuggerRequestArguments } from './debugger-request-schema';

// Sends requests to the debug session and posts results back to the webview.
export class DebuggerRequestHandler {
private readonly _webview: vscode.Webview;

public constructor(webview: vscode.Webview) {
this._webview = webview;
}

public async handleDebuggerRequest(request: string, args?: unknown): Promise<void> {
const session = vscode.debug.activeDebugSession;
if (!session) {
this._webview.postMessage({
type: 'debugger-request-result',
request,
status: 'error',
error: 'No active debug session',
});
return;
}

const requestArgs: DebuggerRequestArguments = {
request,
args,
};

await this.sendDebuggerRequestResult(session, request, requestArgs);
}

public async sendDebuggerRequestResult(
session: vscode.DebugSession,
request: string,
requestArgs: DebuggerRequestArguments,
): Promise<void> {
try {
// Send the request to the debug session and wait for a response
const responsePayload = await session.customRequest('debugger-request', requestArgs);

this._webview.postMessage({
type: 'debugger-request-result',
request,
status: 'ok',
response: responsePayload,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);

this._webview.postMessage({
type: 'debugger-request-result',
request,
status: 'error',
error: errorMessage,
});
}
}
}
26 changes: 26 additions & 0 deletions src/requests/debugger-request-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright (C) Microsoft Corporation. All rights reserved.

// Sent from the webview to the debug session
export interface DebuggerRequestArguments {
request: string;
args?: unknown;
}

// Sent from the debug session to the debuggee (MC)
export interface DebuggerRequestEnvelope {
type: 'debugger-request';
request: {
request_seq: number;
request: string;
args?: unknown;
};
}

// Received from the debuggee (MC) in response to a DebuggerRequestEnvelope
export interface DebuggeeResponseEnvelope {
type: 'debuggee-response';
request_seq: number;
args?: unknown;
success?: boolean;
response_message?: string;
}
118 changes: 118 additions & 0 deletions src/requests/request-manager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Copyright (C) Microsoft Corporation. All rights reserved.

import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import type { DebugProtocol } from '@vscode/debugprotocol';
import { RequestManager } from './request-manager';
import type { IDebuggeeMessageSender } from '../debuggee-message-sender';

describe('RequestManager', () => {
let manager: RequestManager;
let mockSender: IDebuggeeMessageSender;

beforeEach(() => {
mockSender = {
sendDebuggeeMessage: vi.fn(),
sendDebugeeRequestAsync: vi.fn(),
} as unknown as IDebuggeeMessageSender;
});

describe('sendDebuggerRequest', () => {
it('should send request envelope and resolve on successful response', async () => {
manager = new RequestManager(mockSender);
const response = { request_seq: 42 } as DebugProtocol.Response;

const promise = manager.sendDebuggerRequest(response, {
request: 'test-debugger-request',
args: { foo: 'bar' },
});

const handled = manager.handleDebuggeeResponse({
type: 'debuggee-response',
request_seq: 42,
success: true,
args: { ok: true },
});

expect(mockSender.sendDebuggeeMessage as Mock).toHaveBeenCalledTimes(1);
expect(mockSender.sendDebuggeeMessage as Mock).toHaveBeenNthCalledWith(1, {
type: 'debugger-request',
request: {
request_seq: 42,
request: 'test-debugger-request',
args: { foo: 'bar' },
},
});
expect(handled).toBe(true);
await expect(promise).resolves.toEqual({
type: 'debuggee-response',
request_seq: 42,
success: true,
args: { ok: true },
});
});

it('should time out when no response is received', async () => {
vi.useFakeTimers();
manager = new RequestManager(mockSender);
const response = { request_seq: 123 } as DebugProtocol.Response;

const promise = manager.sendDebuggerRequest(response, {
request: 'test-debugger-request',
});
const rejection = expect(promise).rejects.toThrow(
"Debugger request 'test-debugger-request' timed out after 10000ms.",
);
await vi.advanceTimersByTimeAsync(10000);

await rejection;
vi.useRealTimers();
});
});

describe('handleDebuggeeResponse', () => {
it('should reject request on failed response', async () => {
manager = new RequestManager(mockSender);
const response = { request_seq: 7 } as DebugProtocol.Response;
const promise = manager.sendDebuggerRequest(response, {
request: 'test-debugger-request',
});

manager.handleDebuggeeResponse({
type: 'debuggee-response',
request_seq: 7,
success: false,
response_message: 'Denied',
});

await expect(promise).rejects.toThrow('Denied');
});

it('should return false for unknown response sequence', () => {
manager = new RequestManager(mockSender);

const handled = manager.handleDebuggeeResponse({
type: 'debuggee-response',
request_seq: -1,
success: true,
});

expect(handled).toBe(false);
});
});

describe('rejectPendingRequests', () => {
it('should reject all pending requests when session disconnects', async () => {
manager = new RequestManager(mockSender);
const responseA = { request_seq: 1 } as DebugProtocol.Response;
const responseB = { request_seq: 2 } as DebugProtocol.Response;

const promiseA = manager.sendDebuggerRequest(responseA, { request: 'A' });
const promiseB = manager.sendDebuggerRequest(responseB, { request: 'B' });

manager.rejectPendingRequests('Disconnected');

await expect(promiseA).rejects.toThrow('Disconnected');
await expect(promiseB).rejects.toThrow('Disconnected');
});
});
});
Loading
Loading