diff --git a/packages/debug/src/browser/debug-session.ts b/packages/debug/src/browser/debug-session.ts index fb24659e1e..8938e5a986 100644 --- a/packages/debug/src/browser/debug-session.ts +++ b/packages/debug/src/browser/debug-session.ts @@ -32,11 +32,12 @@ import { IRuntimeBreakpoint, BreakpointsChangeEvent, IDebugBreakpoint, + IMemoryRegion, } from '../common'; import { DebugConfiguration } from '../common'; import { DebugEditor } from './../common/debug-editor'; -import { IDebugModel } from './../common/debug-model'; +import { IDebugModel, MemoryRegion } from './../common/debug-model'; import { BreakpointManager, DebugBreakpoint } from './breakpoint'; import { DebugSessionConnection } from './debug-session-connection'; import { DebugModelManager } from './editor/debug-model-manager'; @@ -92,6 +93,9 @@ export class DebugSession implements IDebugSession { private readonly _onDidChangeState = new Emitter(); readonly onDidChangeState: Event = this._onDidChangeState.event; + private readonly _onDidInvalidMemory = new Emitter(); + readonly onDidInvalidateMemory: Event = this._onDidInvalidMemory.event; + protected readonly toDispose = new DisposableCollection(); protected _capabilities: DebugProtocol.Capabilities = {}; @@ -241,6 +245,9 @@ export class DebugSession implements IDebugSession { this.on('progressEnd', (event: DebugProtocol.ProgressEndEvent) => { this._onDidProgressEnd.fire(event); }), + this.on('memory', (event: DebugProtocol.MemoryEvent) => { + this._onDidInvalidMemory.fire(event); + }), this.on('invalidated', async (event: DebugProtocol.InvalidatedEvent) => { this._onDidInvalidated.fire(event); @@ -271,6 +278,10 @@ export class DebugSession implements IDebugSession { ]); } + getMemory(memoryReference: string): IMemoryRegion { + return new MemoryRegion(memoryReference, this); + } + get configuration(): DebugConfiguration { return this.options.configuration; } @@ -1119,4 +1130,31 @@ export class DebugSession implements IDebugSession { public getModel(): IDebugModel | undefined { return this.modelManager.model; } + + // memory + + public async readMemory( + memoryReference: string, + offset: number, + count: number, + ): Promise { + if (this.capabilities.supportsReadMemoryRequest) { + return await this.sendRequest('readMemory', { count, memoryReference, offset }); + } + return Promise.resolve(undefined); + } + + public async writeMemory( + memoryReference: string, + offset: number, + data: string, + allowPartial?: boolean, + ): Promise { + if (this.capabilities.supportsWriteMemoryRequest) { + return await this.sendRequest('writeMemory', { memoryReference, offset, allowPartial, data }); + } + return Promise.resolve(undefined); + } + + // memory end } diff --git a/packages/debug/src/common/debug-model.ts b/packages/debug/src/common/debug-model.ts index dc9c4ffaf6..5a28843cff 100644 --- a/packages/debug/src/common/debug-model.ts +++ b/packages/debug/src/common/debug-model.ts @@ -1,6 +1,17 @@ import stream from 'stream'; -import { IDisposable, MaybePromise, IJSONSchema, IJSONSchemaSnippet, URI } from '@opensumi/ide-core-common'; +import { + IDisposable, + MaybePromise, + IJSONSchema, + IJSONSchemaSnippet, + URI, + Disposable, + Emitter, + BinaryBuffer, + decodeBase64, + encodeBase64, +} from '@opensumi/ide-core-common'; import type { editor } from '@opensumi/monaco-editor-core'; import * as monaco from '@opensumi/monaco-editor-core/esm/vs/editor/editor.api'; @@ -13,6 +24,8 @@ import { import { DebugConfiguration } from './debug-configuration'; import { DebugEditor } from './debug-editor'; import { IDebugHoverWidget } from './debug-hover'; +import { IMemoryInvalidationEvent, IMemoryRegion, MemoryRange, MemoryRangeType } from './debug-service'; +import { IDebugSession } from './debug-session'; export interface IDebugBreakpointWidget extends IDisposable { position: monaco.Position | undefined; @@ -160,3 +173,75 @@ export interface IDebugModel extends IDisposable { getDebugHoverWidget: () => IDebugHoverWidget; render: () => void; } + +export class MemoryRegion extends Disposable implements IMemoryRegion { + private readonly invalidateEmitter = this.registerDispose(new Emitter()); + + /** @inheritdoc */ + public readonly onDidInvalidate = this.invalidateEmitter.event; + + /** @inheritdoc */ + public readonly writable = !!this.session.capabilities.supportsWriteMemoryRequest; + + constructor(private readonly memoryReference: string, private readonly session: IDebugSession) { + super(); + this.registerDispose( + session.onDidInvalidateMemory((e) => { + if (e.body.memoryReference === memoryReference) { + this.invalidate(e.body.offset, e.body.count - e.body.offset); + } + }), + ); + } + + public async read(fromOffset: number, toOffset: number): Promise { + const length = toOffset - fromOffset; + const offset = fromOffset; + const result = await this.session.readMemory(this.memoryReference, offset, length); + + if (result === undefined || !result.body?.data) { + return [{ type: MemoryRangeType.Unreadable, offset, length }]; + } + + let data: BinaryBuffer; + try { + data = decodeBase64(result.body.data); + } catch { + return [{ type: MemoryRangeType.Error, offset, length, error: 'Invalid base64 data from debug adapter' }]; + } + + const unreadable = result.body.unreadableBytes || 0; + const dataLength = length - unreadable; + if (data.byteLength < dataLength) { + const pad = BinaryBuffer.alloc(dataLength - data.byteLength); + pad.buffer.fill(0); + data = BinaryBuffer.concat([data, pad], dataLength); + } else if (data.byteLength > dataLength) { + data = data.slice(0, dataLength); + } + + if (!unreadable) { + return [{ type: MemoryRangeType.Valid, offset, length, data }]; + } + + return [ + { type: MemoryRangeType.Valid, offset, length: dataLength, data }, + { type: MemoryRangeType.Unreadable, offset: offset + dataLength, length: unreadable }, + ]; + } + + public async write(offset: number, data: BinaryBuffer): Promise { + const result = await this.session.writeMemory(this.memoryReference, offset, encodeBase64(data), true); + const written = result?.body?.bytesWritten ?? data.byteLength; + this.invalidate(offset, offset + written); + return written; + } + + public override dispose() { + super.dispose(); + } + + private invalidate(fromOffset: number, toOffset: number) { + this.invalidateEmitter.fire({ fromOffset, toOffset }); + } +} diff --git a/packages/debug/src/common/debug-service.ts b/packages/debug/src/common/debug-service.ts index 7d5fa09384..72b0e501ba 100644 --- a/packages/debug/src/common/debug-service.ts +++ b/packages/debug/src/common/debug-service.ts @@ -1,8 +1,84 @@ -import { IDisposable, IJSONSchema, IJSONSchemaSnippet, ApplicationError, Event } from '@opensumi/ide-core-common'; +import { + IDisposable, + IJSONSchema, + IJSONSchemaSnippet, + ApplicationError, + Event, + BinaryBuffer, +} from '@opensumi/ide-core-common'; import { DebugConfiguration } from './debug-configuration'; import { IDebugSessionDTO } from './debug-session-options'; +export interface IMemoryInvalidationEvent { + fromOffset: number; + toOffset: number; +} + +export const enum MemoryRangeType { + Valid, + Unreadable, + Error, +} + +export interface IMemoryRange { + type: MemoryRangeType; + offset: number; + length: number; +} + +export interface IUnreadableMemoryRange extends IMemoryRange { + type: MemoryRangeType.Unreadable; +} + +export interface IErrorMemoryRange extends IMemoryRange { + type: MemoryRangeType.Error; + error: string; +} + +export interface IValidMemoryRange extends IMemoryRange { + type: MemoryRangeType.Valid; + offset: number; + length: number; + data: BinaryBuffer; +} + +/** + * Union type of memory that can be returned from read(). Since a read request + * could encompass multiple previously-read ranges, multiple of these types + * are possible to return. + */ +export type MemoryRange = IValidMemoryRange | IUnreadableMemoryRange | IErrorMemoryRange; + +/** + * An IMemoryRegion corresponds to a contiguous range of memory referred to + * by a DAP `memoryReference`. + */ +export interface IMemoryRegion extends IDisposable { + /** + * Event that fires when memory changes. Can be a result of memory events or + * `write` requests. + */ + readonly onDidInvalidate: Event; + + /** + * Whether writes are supported on this memory region. + */ + readonly writable: boolean; + + /** + * Requests memory ranges from the debug adapter. It returns a list of memory + * ranges that overlap (but may exceed!) the given offset. Use the `offset` + * and `length` of each range for display. + */ + read(fromOffset: number, toOffset: number): Promise; + + /** + * Writes memory to the debug adapter at the given offset. + */ + write(offset: number, data: BinaryBuffer): Promise; +} + export interface DebuggerDescription { type: string; label: string; diff --git a/packages/debug/src/common/debug-session.ts b/packages/debug/src/common/debug-session.ts index 4e618f3bdc..7aad771e74 100644 --- a/packages/debug/src/common/debug-session.ts +++ b/packages/debug/src/common/debug-session.ts @@ -1,4 +1,4 @@ -import { CancellationToken, IDisposable } from '@opensumi/ide-core-common'; +import { CancellationToken, Event, IDisposable } from '@opensumi/ide-core-common'; import { DebugProtocol } from '@opensumi/vscode-debugprotocol'; import { DebugConfiguration } from './debug-configuration'; @@ -45,6 +45,19 @@ export interface IDebugSession extends IDisposable { state: DebugState; parentSession: IDebugSession | undefined; id: string; + capabilities: DebugProtocol.Capabilities; + onDidInvalidateMemory: Event; + readMemory( + memoryReference: string, + offset: number, + count: number, + ): Promise; + writeMemory( + memoryReference: string, + offset: number, + data: string, + allowPartial?: boolean | undefined, + ): Promise; hasSeparateRepl: () => boolean; getDebugProtocolBreakpoint(breakpointId: string): DebugProtocol.Breakpoint | undefined; compact: boolean; @@ -155,6 +168,8 @@ export interface DebugRequestTypes { threads: [DebugProtocol.ThreadsArguments | null, DebugProtocol.ThreadsResponse]; variables: [DebugProtocol.VariablesArguments, DebugProtocol.VariablesResponse]; cancel: [DebugProtocol.CancelArguments, DebugProtocol.CancelResponse]; + readMemory: [DebugProtocol.ReadMemoryArguments, DebugProtocol.ReadMemoryResponse]; + writeMemory: [DebugProtocol.WriteMemoryArguments, DebugProtocol.WriteMemoryResponse]; } export interface DebugEventTypes { @@ -173,5 +188,6 @@ export interface DebugEventTypes { progressStart: DebugProtocol.ProgressStartEvent; progressUpdate: DebugProtocol.ProgressUpdateEvent; progressEnd: DebugProtocol.ProgressEndEvent; + memory: DebugProtocol.MemoryEvent; invalidated: DebugProtocol.InvalidatedEvent; } diff --git a/packages/utils/src/buffer.ts b/packages/utils/src/buffer.ts index 9f036b6b56..b58a90dc87 100644 --- a/packages/utils/src/buffer.ts +++ b/packages/utils/src/buffer.ts @@ -183,3 +183,108 @@ export function readUInt8(source: Uint8Array, offset: number): number { export function writeUInt8(destination: Uint8Array, value: number, offset: number): void { destination[offset] = value; } + +/** Decodes base64 to a uint8 array. URL-encoded and unpadded base64 is allowed. */ +export function decodeBase64(encoded: string) { + let building = 0; + let remainder = 0; + let bufi = 0; + + // The simpler way to do this is `Uint8Array.from(atob(str), c => c.charCodeAt(0))`, + // but that's about 10-20x slower than this function in current Chromium versions. + + const buffer = new Uint8Array(Math.floor((encoded.length / 4) * 3)); + const append = (value: number) => { + switch (remainder) { + case 3: + buffer[bufi++] = building | value; + remainder = 0; + break; + case 2: + buffer[bufi++] = building | (value >>> 2); + building = value << 6; + remainder = 3; + break; + case 1: + buffer[bufi++] = building | (value >>> 4); + building = value << 4; + remainder = 2; + break; + default: + building = value << 2; + remainder = 1; + } + }; + + for (let i = 0; i < encoded.length; i++) { + const code = encoded.charCodeAt(i); + // See https://datatracker.ietf.org/doc/html/rfc4648#section-4 + // This branchy code is about 3x faster than an indexOf on a base64 char string. + if (code >= 65 && code <= 90) { + append(code - 65); // A-Z starts ranges from char code 65 to 90 + } else if (code >= 97 && code <= 122) { + append(code - 97 + 26); // a-z starts ranges from char code 97 to 122, starting at byte 26 + } else if (code >= 48 && code <= 57) { + append(code - 48 + 52); // 0-9 starts ranges from char code 48 to 58, starting at byte 52 + } else if (code === 43 || code === 45) { + append(62); // "+" or "-" for URLS + } else if (code === 47 || code === 95) { + append(63); // "/" or "_" for URLS + } else if (code === 61) { + break; // "=" + } else { + throw new SyntaxError(`Unexpected base64 character ${encoded[i]}`); + } + } + + const unpadded = bufi; + while (remainder > 0) { + append(0); + } + + // slice is needed to account for overestimation due to padding + return BinaryBuffer.wrap(buffer).slice(0, unpadded); +} + +const base64Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; +const base64UrlSafeAlphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + +/** Encodes a buffer to a base64 string. */ +export function encodeBase64({ buffer }: BinaryBuffer, padded = true, urlSafe = false) { + const dictionary = urlSafe ? base64UrlSafeAlphabet : base64Alphabet; + let output = ''; + + const remainder = buffer.byteLength % 3; + + let i = 0; + for (; i < buffer.byteLength - remainder; i += 3) { + const a = buffer[i + 0]; + const b = buffer[i + 1]; + const c = buffer[i + 2]; + + output += dictionary[a >>> 2]; + output += dictionary[((a << 4) | (b >>> 4)) & 0b111111]; + output += dictionary[((b << 2) | (c >>> 6)) & 0b111111]; + output += dictionary[c & 0b111111]; + } + + if (remainder === 1) { + const a = buffer[i + 0]; + output += dictionary[a >>> 2]; + output += dictionary[(a << 4) & 0b111111]; + if (padded) { + output += '=='; + } + } else if (remainder === 2) { + const a = buffer[i + 0]; + const b = buffer[i + 1]; + output += dictionary[a >>> 2]; + output += dictionary[((a << 4) | (b >>> 4)) & 0b111111]; + output += dictionary[(b << 2) & 0b111111]; + if (padded) { + output += '='; + } + } + + return output; +}