feat(debugger): integrate NX Tcl Remote Debugger into NX Postprocessor
build_and_puplish.yml / build_and_publish (release) Successful in 35s

Embed the NX Tcl Remote Debugger into the NX Postprocessor extension.
Add a new debugging client, protocol, and adapter logic to
drive attach/launch, breakpoints, stepping, and evaluation
via the runtime adapter.

- Introduced a client debugger with a new adapter and protocol
- Wired attach/launch, breakpoints, stepping, and evaluation
- Updated docs and licensing to reflect the embedded debugger
This commit is contained in:
Christoph Brandau
2026-08-28 22:36:18 +02:00
parent 07ccd2d26a
commit c3d5116885
12 changed files with 1135 additions and 10 deletions
+650
View File
@@ -0,0 +1,650 @@
'use strict';
const path = require('path');
const { RuntimeConnection, encodeHex, decodeHex } = require('./protocol');
const THREAD_ID = 1;
class NxDebugAdapter {
constructor(input = process.stdin, output = process.stdout) {
this.input = input;
this.output = output;
this.inputBuffer = Buffer.alloc(0);
this.sequence = 1;
this.runtime = null;
this.terminated = false;
this.explicitDisconnect = false;
this.localRoot = '';
this.remoteRoot = '';
this.nextBreakpointId = 1;
// VS Code can expose the same physical file through more than one source
// identity (for example, drive mappings or differently cased workspace
// roots). SETBPS replaces all breakpoints for a runtime path, so retain
// each DAP source group and send their union to NX.
this.sourceBreakpointGroups = new Map();
this.pendingStartRequest = null;
this.resumeInProgress = 0;
this.pendingStoppedEvents = [];
this.isPaused = false;
// DAP frame/variable IDs can outlive a stop in the VS Code UI. Runtime
// levels and references, however, are valid only for the current Tcl
// pause. Give every stop adapter-owned IDs so a late request can never
// accidentally address a same-numbered frame from a later pause.
this.nextFrameId = 1;
this.frameReferences = new Map();
this.runtimeToFrameReference = new Map();
this.nextVariablesReference = 1;
this.variableReferences = new Map();
this.runtimeToVariableReference = new Map();
input.on('data', (chunk) => this.onData(chunk));
input.on('end', () => this.dispose());
input.resume();
}
onData(chunk) {
this.inputBuffer = Buffer.concat([this.inputBuffer, chunk]);
while (true) {
const headerEnd = this.inputBuffer.indexOf('\r\n\r\n');
if (headerEnd < 0) return;
const header = this.inputBuffer.subarray(0, headerEnd).toString('ascii');
const match = /Content-Length:\s*(\d+)/i.exec(header);
if (!match) throw new Error('missing DAP Content-Length header');
const contentLength = Number(match[1]);
const messageEnd = headerEnd + 4 + contentLength;
if (this.inputBuffer.length < messageEnd) return;
const payload = this.inputBuffer.subarray(headerEnd + 4, messageEnd).toString('utf8');
this.inputBuffer = this.inputBuffer.subarray(messageEnd);
const message = JSON.parse(payload);
if (message.type === 'request') {
this.handleRequest(message).catch((error) => this.sendErrorResponse(message, error));
}
}
}
send(message) {
message.seq = this.sequence++;
const json = JSON.stringify(message);
this.output.write(`Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n${json}`);
}
sendResponse(request, body = {}) {
this.send({
type: 'response',
request_seq: request.seq,
success: true,
command: request.command,
body,
});
}
sendErrorResponse(request, error) {
this.send({
type: 'response',
request_seq: request.seq,
success: false,
command: request.command,
message: error instanceof Error ? error.message : String(error),
});
}
sendEvent(event, body = {}) {
this.send({ type: 'event', event, body });
}
async handleRequest(request) {
const args = request.arguments || {};
switch (request.command) {
case 'initialize':
this.sendResponse(request, {
supportsConfigurationDoneRequest: true,
supportsConditionalBreakpoints: true,
supportsHitConditionalBreakpoints: true,
supportsLogPoints: true,
supportsEvaluateForHovers: true,
supportsSetVariable: true,
supportsExceptionFilterOptions: true,
exceptionBreakpointFilters: [
{ filter: 'tclError', label: 'Tcl errors', description: 'Break when a Tcl command returns TCL_ERROR' },
],
supportsTerminateRequest: true,
});
break;
case 'attach':
case 'launch':
await this.attach(args);
this.pendingStartRequest = request;
this.sendEvent('initialized');
break;
case 'configurationDone':
await this.runtime.request('CONFIGDONE');
this.sendResponse(request);
if (this.pendingStartRequest) {
this.sendResponse(this.pendingStartRequest);
this.pendingStartRequest = null;
}
break;
case 'setBreakpoints':
await this.setBreakpoints(request, args);
break;
case 'setExceptionBreakpoints': {
const filters = args.filters || [];
await this.runtime.request('SETEXCEPTIONS', filters);
this.sendResponse(request, {
breakpoints: filters.map(() => ({ verified: true })),
});
break;
}
case 'threads':
this.sendResponse(request, { threads: [{ id: THREAD_ID, name: 'NX Post Tcl' }] });
break;
case 'stackTrace':
await this.stackTrace(request);
break;
case 'scopes':
await this.scopes(request, args);
break;
case 'variables':
await this.variables(request, args);
break;
case 'evaluate':
await this.evaluate(request, args);
break;
case 'setVariable':
await this.setVariable(request, args);
break;
case 'continue':
await this.resume(request, 'CONTINUE', { allThreadsContinued: true });
break;
case 'next':
await this.resume(request, 'NEXT');
break;
case 'stepIn':
await this.resume(request, 'STEPIN');
break;
case 'stepOut':
await this.resume(request, 'STEPOUT');
break;
case 'pause':
await this.runtime.request('PAUSE');
this.sendResponse(request);
break;
case 'disconnect':
case 'terminate':
await this.disconnect(request);
break;
default:
this.sendResponse(request);
break;
}
}
async attach(args) {
const host = args.host || '127.0.0.1';
const port = Number(args.port || 4711);
const timeout = Number(args.connectTimeout || 120000);
this.localRoot = args.localRoot ? path.resolve(args.localRoot) : '';
this.remoteRoot = args.remoteRoot ? this.normalizePath(args.remoteRoot) : '';
this.sourceBreakpointGroups.clear();
this.isPaused = false;
this.resetStoppedReferences(true);
this.runtime = new RuntimeConnection();
this.runtime.on('event', (name, fields) => this.onRuntimeEvent(name, fields));
this.runtime.on('runtimeError', (error) => {
this.sendEvent('output', { category: 'stderr', output: `NX: ${error.message}\n` });
});
this.runtime.on('close', () => {
if (!this.explicitDisconnect) this.sendTerminated();
});
const started = Date.now();
let lastError;
while (Date.now() - started < timeout) {
try {
await this.runtime.connect(host, port);
lastError = null;
break;
} catch (error) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
if (lastError) {
throw new Error(`could not connect to NX at ${host}:${port}: ${lastError.message}`);
}
await this.runtime.request('CONFIG', [args.stopOnEntry ? 1 : 0, args.breakOnError ? 1 : 0]);
}
async setBreakpoints(request, args) {
const sourcePath = args.source && args.source.path ? args.source.path : '';
const remotePath = this.toRemotePath(sourcePath);
const sourceKey = this.breakpointSourceKey(args.source, sourcePath);
const remoteKey = this.breakpointRemoteKey(remotePath);
const requested = args.breakpoints || (args.lines || []).map((line) => ({ line }));
const records = requested.map((breakpoint) => ({
id: this.nextBreakpointId++,
line: breakpoint.line,
condition: breakpoint.condition || '',
hitCondition: breakpoint.hitCondition || '',
logMessage: breakpoint.logMessage || '',
}));
if (!this.isTclSource(sourcePath)) {
this.sourceBreakpointGroups.delete(sourceKey);
// VS Code forwards breakpoints from every language to every active debug
// session. Clear any stale runtime entry left by an older adapter, but do
// not make the NX Tcl runtime parse or instrument a Python source file.
await this.runtime.request('SETBPS', [encodeHex(remotePath), 0]);
this.sendResponse(request, {
breakpoints: records.map((breakpoint) => ({
id: breakpoint.id,
verified: false,
line: breakpoint.line,
source: args.source,
message: 'NX Tcl Remote Debugger supports breakpoints only in .tcl and .def files',
})),
});
return;
}
this.sourceBreakpointGroups.set(sourceKey, { remotePath, remoteKey, records });
// Collapse exact duplicates shared by aliases, while remembering which
// runtime breakpoint represents every DAP breakpoint ID. This keeps hit
// counts and log points from firing twice for one Tcl command.
const mergedBreakpoints = [];
const mergedBySignature = new Map();
const representativeById = new Map();
for (const group of this.sourceBreakpointGroups.values()) {
if (group.remoteKey !== remoteKey) continue;
for (const breakpoint of group.records) {
const signature = this.breakpointSignature(breakpoint);
let representative = mergedBySignature.get(signature);
if (!representative) {
representative = breakpoint;
mergedBySignature.set(signature, representative);
mergedBreakpoints.push(representative);
}
representativeById.set(breakpoint.id, representative.id);
}
}
const fields = [encodeHex(remotePath), mergedBreakpoints.length];
for (const breakpoint of mergedBreakpoints) {
fields.push(
breakpoint.id,
breakpoint.line,
encodeHex(breakpoint.condition),
encodeHex(breakpoint.hitCondition),
encodeHex(breakpoint.logMessage),
);
}
const runtimeResponse = await this.runtime.request('SETBPS', fields);
const responseFields = runtimeResponse.fields || [];
const responseCount = Number(responseFields[0] || 0);
const responseStride = responseFields.length >= 1 + responseCount * 4 ? 4 : 2;
const runtimeBreakpoints = new Map();
let responseCursor = 1;
for (let index = 0; index < responseCount; index++) {
const id = Number(responseFields[responseCursor++]);
const line = Number(responseFields[responseCursor++]);
let verified = true;
let message = '';
if (responseStride === 4) {
verified = responseFields[responseCursor++] === '1';
message = decodeHex(responseFields[responseCursor++]);
}
runtimeBreakpoints.set(id, { line, verified, message });
}
this.sendResponse(request, {
breakpoints: records.map((breakpoint) => {
const representativeId = representativeById.get(breakpoint.id);
const runtimeBreakpoint = runtimeBreakpoints.get(representativeId);
const result = {
id: breakpoint.id,
verified: runtimeBreakpoint ? runtimeBreakpoint.verified : false,
line: runtimeBreakpoint && runtimeBreakpoint.line ? runtimeBreakpoint.line : breakpoint.line,
source: args.source,
};
if (runtimeBreakpoint && runtimeBreakpoint.message) result.message = runtimeBreakpoint.message;
return result;
}),
});
}
async stackTrace(request) {
if (!this.isPaused) {
this.sendResponse(request, { stackFrames: [], totalFrames: 0 });
return;
}
const { fields } = await this.runtime.request('STACK');
const count = Number(fields[0] || 0);
const stackFrames = [];
let cursor = 1;
for (let index = 0; index < count; index++) {
const runtimeId = Number(fields[cursor++]);
const name = decodeHex(fields[cursor++]);
const remoteFile = decodeHex(fields[cursor++]);
const line = Number(fields[cursor++]);
const column = Number(fields[cursor++]);
const localFile = this.toLocalPath(remoteFile);
const frame = {
id: this.toAdapterFrameId(runtimeId),
name: name || '<Tcl>',
line: line || 1,
column: column || 1,
};
if (localFile) frame.source = { name: path.basename(localFile), path: localFile };
stackFrames.push(frame);
}
this.sendResponse(request, { stackFrames, totalFrames: stackFrames.length });
}
async scopes(request, args) {
const runtimeFrameId = this.frameReferences.get(Number(args.frameId));
if (!this.isPaused || runtimeFrameId === undefined) {
this.sendResponse(request, { scopes: [] });
return;
}
const { fields } = await this.runtime.request('SCOPES', [runtimeFrameId]);
const count = Number(fields[0] || 0);
const scopes = [];
let cursor = 1;
for (let index = 0; index < count; index++) {
const variablesReference = this.toAdapterVariablesReference(Number(fields[cursor++]));
const name = decodeHex(fields[cursor++]);
cursor++; // wire label: expensive
const expensive = fields[cursor++] === '1';
scopes.push({ name, variablesReference, expensive });
}
this.sendResponse(request, { scopes });
}
async variables(request, args) {
const runtimeReference = this.variableReferences.get(Number(args.variablesReference));
if (!this.isPaused || runtimeReference === undefined) {
this.sendResponse(request, { variables: [] });
return;
}
const { fields } = await this.runtime.request('VARIABLES', [
runtimeReference,
args.start || 0,
args.count || 0,
encodeHex(args.filter || ''),
]);
const count = Number(fields[0] || 0);
const variables = [];
let cursor = 1;
for (let index = 0; index < count; index++) {
variables.push({
name: decodeHex(fields[cursor++]),
value: decodeHex(fields[cursor++]),
type: decodeHex(fields[cursor++]),
variablesReference: this.toAdapterVariablesReference(Number(fields[cursor++])),
indexedVariables: Number(fields[cursor++]) || undefined,
namedVariables: Number(fields[cursor++]) || undefined,
});
}
this.sendResponse(request, { variables });
}
async evaluate(request, args) {
// VS Code can refresh Watch/Hover expressions after Continue but before
// it has processed the next stopped event. Do not forward that transient
// request to the running NX interpreter (which correctly rejects it).
if (!this.isPaused) {
this.sendResponse(request, {
result: '<NX is running; evaluation is available while paused>',
type: 'unavailable',
variablesReference: 0,
});
return;
}
let runtimeFrameId = 0;
if (Number(args.frameId) > 0) {
runtimeFrameId = this.frameReferences.get(Number(args.frameId));
if (runtimeFrameId === undefined) {
this.sendResponse(request, {
result: '<stack frame is no longer available>',
type: 'unavailable',
variablesReference: 0,
});
return;
}
}
const { fields } = await this.runtime.request('EVALUATE', [runtimeFrameId, encodeHex(args.expression || '')]);
this.sendResponse(request, {
result: decodeHex(fields[0]),
type: decodeHex(fields[1]),
variablesReference: this.toAdapterVariablesReference(Number(fields[2])),
indexedVariables: Number(fields[3]) || undefined,
namedVariables: Number(fields[4]) || undefined,
});
}
async setVariable(request, args) {
const runtimeReference = this.variableReferences.get(Number(args.variablesReference));
if (!this.isPaused || runtimeReference === undefined) {
throw new Error('variable is no longer available');
}
const { fields } = await this.runtime.request('SETVARIABLE', [
runtimeReference,
encodeHex(args.name),
encodeHex(args.value),
]);
this.sendResponse(request, {
value: decodeHex(fields[0]),
type: decodeHex(fields[1]),
variablesReference: this.toAdapterVariablesReference(Number(fields[2])),
indexedVariables: Number(fields[3]) || undefined,
namedVariables: Number(fields[4]) || undefined,
});
}
async resume(request, command, responseBody = {}) {
this.isPaused = false;
this.resetStoppedReferences();
this.resumeInProgress++;
try {
await this.runtime.request(command);
// The runtime can hit the next breakpoint immediately and place its
// STOPPED event in the same TCP packet as this response. Always publish
// the DAP resume transition before releasing such a queued stop.
this.sendResponse(request, responseBody);
this.sendEvent('continued', { threadId: THREAD_ID, allThreadsContinued: true });
} finally {
this.resumeInProgress--;
if (this.resumeInProgress === 0) this.flushStoppedEvents();
}
}
async disconnect(request) {
this.explicitDisconnect = true;
this.isPaused = false;
this.resetStoppedReferences();
if (this.runtime && !this.runtime.closed) {
try {
await this.runtime.request('DISCONNECT', [], 2000);
} catch (_) {
// The runtime may close immediately after acknowledging detach.
}
this.runtime.close();
}
this.sendResponse(request);
this.sendTerminated();
}
onRuntimeEvent(name, fields) {
switch (name) {
case 'HELLO':
this.sendEvent('output', {
category: 'console',
output: `NX runtime ${decodeHex(fields[0])}, Tcl ${decodeHex(fields[1])}, PID ${fields[2]}\n`,
});
break;
case 'STOPPED':
this.isPaused = true;
this.resetStoppedReferences();
this.publishStoppedEvent({
reason: decodeHex(fields[0]) || 'pause',
threadId: THREAD_ID,
allThreadsStopped: true,
text: decodeHex(fields[3]) || undefined,
});
break;
case 'OUTPUT':
this.sendEvent('output', { category: 'stdout', output: decodeHex(fields[0]) });
break;
default:
this.sendEvent('output', { category: 'console', output: `NX event ${name}\n` });
}
}
publishStoppedEvent(body) {
if (this.resumeInProgress > 0) {
this.pendingStoppedEvents.push(body);
return;
}
this.sendEvent('stopped', body);
}
flushStoppedEvents() {
const queued = this.pendingStoppedEvents;
this.pendingStoppedEvents = [];
for (const body of queued) this.sendEvent('stopped', body);
}
resetStoppedReferences(resetCounters = false) {
this.frameReferences.clear();
this.runtimeToFrameReference.clear();
this.variableReferences.clear();
this.runtimeToVariableReference.clear();
if (resetCounters) {
this.nextFrameId = 1;
this.nextVariablesReference = 1;
}
}
toAdapterFrameId(runtimeId) {
const normalized = Number(runtimeId);
if (!Number.isFinite(normalized) || normalized <= 0) return 0;
let adapterId = this.runtimeToFrameReference.get(normalized);
if (adapterId !== undefined) return adapterId;
adapterId = this.nextFrameId++;
this.runtimeToFrameReference.set(normalized, adapterId);
this.frameReferences.set(adapterId, normalized);
return adapterId;
}
toAdapterVariablesReference(runtimeReference) {
const normalized = Number(runtimeReference);
if (!Number.isFinite(normalized) || normalized <= 0) return 0;
let adapterReference = this.runtimeToVariableReference.get(normalized);
if (adapterReference !== undefined) return adapterReference;
adapterReference = this.nextVariablesReference++;
this.runtimeToVariableReference.set(normalized, adapterReference);
this.variableReferences.set(adapterReference, normalized);
return adapterReference;
}
isTclSource(sourcePath) {
const extension = path.extname(String(sourcePath || '')).toLowerCase();
return extension === '.tcl' || extension === '.def';
}
breakpointSourceKey(source = {}, sourcePath = '') {
if (source && Number(source.sourceReference) > 0) {
return `reference:${source.sourceReference}`;
}
// Deliberately preserve spelling and case here: they are precisely what
// distinguish two VS Code source identities that map to the same runtime
// path. Runtime comparison is handled separately below.
return `path:${String(sourcePath || '')}`;
}
breakpointRemoteKey(remotePath) {
let normalized = this.normalizePath(remotePath);
if (process.platform === 'win32' || /^[A-Za-z]:\//.test(normalized) || normalized.startsWith('//')) {
normalized = normalized.toLowerCase();
}
return normalized;
}
breakpointSignature(breakpoint) {
return JSON.stringify([
Number(breakpoint.line),
breakpoint.condition || '',
breakpoint.hitCondition || '',
breakpoint.logMessage || '',
]);
}
normalizePath(value) {
const normalized = String(value || '').replace(/\\/g, '/');
if (normalized === '/' || /^[A-Za-z]:\/$/.test(normalized)) return normalized;
return normalized.replace(/\/+$/, '');
}
samePathPrefix(candidate, prefix) {
let normalizedCandidate = this.normalizePath(candidate);
let normalizedPrefix = this.normalizePath(prefix);
if (process.platform === 'win32') {
normalizedCandidate = normalizedCandidate.toLowerCase();
normalizedPrefix = normalizedPrefix.toLowerCase();
}
if (normalizedCandidate === normalizedPrefix) return true;
const boundaryPrefix = normalizedPrefix.endsWith('/') ? normalizedPrefix : `${normalizedPrefix}/`;
return normalizedCandidate.startsWith(boundaryPrefix);
}
toRemotePath(localPath) {
const normalized = this.normalizePath(path.resolve(localPath || '.'));
const localRoot = this.normalizePath(this.localRoot);
if (localRoot && this.remoteRoot && this.samePathPrefix(normalized, localRoot)) {
return this.remoteRoot + normalized.slice(localRoot.length);
}
return normalized;
}
toLocalPath(remotePath) {
const normalized = this.normalizePath(remotePath);
if (this.remoteRoot && this.localRoot && this.samePathPrefix(normalized, this.remoteRoot)) {
const suffix = normalized.slice(this.remoteRoot.length).replace(/^\//, '');
return path.join(this.localRoot, ...suffix.split('/'));
}
return process.platform === 'win32' ? normalized.replace(/\//g, '\\') : normalized;
}
sendTerminated() {
if (this.terminated) return;
this.terminated = true;
this.sendEvent('terminated');
}
dispose() {
if (this.runtime) this.runtime.close();
}
}
if (require.main === module) {
new NxDebugAdapter();
}
module.exports = { NxDebugAdapter, THREAD_ID };
+68
View File
@@ -0,0 +1,68 @@
'use strict';
const { PassThrough } = require('node:stream');
const { NxDebugAdapter } = require('./debugAdapter');
function frameMessage(message) {
const json = JSON.stringify(message);
return `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n${json}`;
}
class NxInlineDebugAdapter {
constructor() {
this.input = new PassThrough();
this.output = new PassThrough();
this.outputBuffer = Buffer.alloc(0);
this.listeners = new Set();
this.disposed = false;
this.onDidSendMessage = (listener, thisArgs, disposables) => {
const registration = { listener, thisArgs };
this.listeners.add(registration);
const disposable = {
dispose: () => this.listeners.delete(registration),
};
if (Array.isArray(disposables)) disposables.push(disposable);
return disposable;
};
this.output.on('data', (chunk) => this.onOutput(chunk));
this.adapter = new NxDebugAdapter(this.input, this.output);
}
handleMessage(message) {
if (!this.disposed) this.input.write(frameMessage(message));
}
onOutput(chunk) {
this.outputBuffer = Buffer.concat([this.outputBuffer, chunk]);
while (true) {
const headerEnd = this.outputBuffer.indexOf('\r\n\r\n');
if (headerEnd < 0) return;
const header = this.outputBuffer.subarray(0, headerEnd).toString('ascii');
const match = /Content-Length:\s*(\d+)/i.exec(header);
if (!match) throw new Error('missing DAP Content-Length header from NX adapter');
const contentLength = Number(match[1]);
const messageEnd = headerEnd + 4 + contentLength;
if (this.outputBuffer.length < messageEnd) return;
const payload = this.outputBuffer.subarray(headerEnd + 4, messageEnd).toString('utf8');
this.outputBuffer = this.outputBuffer.subarray(messageEnd);
const message = JSON.parse(payload);
for (const { listener, thisArgs } of [...this.listeners]) {
listener.call(thisArgs, message);
}
}
}
dispose() {
if (this.disposed) return;
this.disposed = true;
this.adapter.dispose();
this.input.end();
this.output.destroy();
this.listeners.clear();
}
}
module.exports = { NxInlineDebugAdapter, frameMessage };
+116
View File
@@ -0,0 +1,116 @@
'use strict';
const net = require('net');
const { EventEmitter } = require('events');
function encodeHex(value) {
return Buffer.from(String(value ?? ''), 'utf8').toString('hex') || '-';
}
function decodeHex(value) {
if (!value || value === '-') return '';
return Buffer.from(value, 'hex').toString('utf8');
}
class RuntimeConnection extends EventEmitter {
constructor() {
super();
this.socket = null;
this.buffer = '';
this.nextRequestId = 1;
this.pending = new Map();
this.closed = false;
}
connect(host, port) {
return new Promise((resolve, reject) => {
const socket = net.createConnection({ host, port });
const onError = (error) => {
socket.destroy();
reject(error);
};
socket.once('error', onError);
socket.once('connect', () => {
socket.removeListener('error', onError);
this.socket = socket;
this.closed = false;
socket.setEncoding('utf8');
socket.on('data', (chunk) => this.onData(chunk));
socket.on('error', (error) => this.emit('runtimeError', error));
socket.on('close', () => this.onClose());
resolve();
});
});
}
request(command, fields = [], timeoutMs = 30000) {
if (!this.socket || this.closed) {
return Promise.reject(new Error('NX runtime is not connected'));
}
const id = this.nextRequestId++;
const normalized = fields.map((field) => String(field ?? ''));
if (normalized.some((field) => field.includes('\t') || field.includes('\n'))) {
return Promise.reject(new Error('protocol fields must be hex-encoded before transmission'));
}
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`NX ${command} request timed out`));
}, timeoutMs);
this.pending.set(id, { resolve, reject, timer, command });
this.socket.write(['REQ', id, command, ...normalized].join('\t') + '\n');
});
}
close() {
this.closed = true;
if (this.socket) this.socket.destroy();
this.socket = null;
}
onData(chunk) {
this.buffer += chunk;
let newline;
while ((newline = this.buffer.indexOf('\n')) >= 0) {
const line = this.buffer.slice(0, newline).replace(/\r$/, '');
this.buffer = this.buffer.slice(newline + 1);
if (line) this.onLine(line);
}
}
onLine(line) {
const fields = line.split('\t');
if (fields[0] === 'RES') {
const id = Number(fields[1]);
const pending = this.pending.get(id);
if (!pending) return;
this.pending.delete(id);
clearTimeout(pending.timer);
if (fields[2] === 'OK') {
pending.resolve({ command: fields[3], fields: fields.slice(4) });
} else {
pending.reject(new Error(decodeHex(fields[4]) || `NX ${pending.command} failed`));
}
return;
}
if (fields[0] === 'EVENT') {
this.emit('event', fields[1], fields.slice(2));
return;
}
this.emit('runtimeError', new Error(`unrecognized NX message: ${line}`));
}
onClose() {
if (this.closed) return;
this.closed = true;
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(new Error('NX runtime disconnected'));
}
this.pending.clear();
this.emit('close');
}
}
module.exports = { RuntimeConnection, encodeHex, decodeHex };
+42
View File
@@ -0,0 +1,42 @@
import * as vscode from "vscode"
// The adapter is shared with the standalone NX Tcl Remote Debugger. It is
// bundled by esbuild into this extension, so no child process or additional
// VS Code extension is required.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { NxInlineDebugAdapter } = require("./inlineAdapter")
class NxDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory {
createDebugAdapterDescriptor(): vscode.ProviderResult<vscode.DebugAdapterDescriptor> {
return new vscode.DebugAdapterInlineImplementation(new NxInlineDebugAdapter())
}
}
function resolveDebugConfiguration(
folder: vscode.WorkspaceFolder | undefined,
config: vscode.DebugConfiguration
): vscode.DebugConfiguration {
const resolved = { ...config }
if (!resolved.type) resolved.type = "nx-tcl"
if (!resolved.request) resolved.request = "attach"
if (!resolved.name) resolved.name = "Attach to NX Post Tcl"
if (!resolved.host) resolved.host = "127.0.0.1"
if (!resolved.port) resolved.port = 4711
if (!resolved.connectTimeout) resolved.connectTimeout = 120000
if (resolved.stopOnEntry === undefined) resolved.stopOnEntry = false
if (resolved.breakOnError === undefined) resolved.breakOnError = true
if (!resolved.localRoot && folder) resolved.localRoot = folder.uri.fsPath
return resolved
}
export function registerNxTclDebugger(context: vscode.ExtensionContext): void {
const factory = new NxDebugAdapterFactory()
context.subscriptions.push(
vscode.debug.registerDebugAdapterDescriptorFactory("nx-tcl", factory),
vscode.debug.registerDebugConfigurationProvider("nx-tcl", {
resolveDebugConfiguration
})
)
}
export { resolveDebugConfiguration }
+3
View File
@@ -29,10 +29,13 @@ import { checkIfConfigurationChanged, getInterpreterFromSetting } from "./common
import { loadServerDefaults } from "./common/setup"
import { getLSClientTraceLevel } from "./common/utilities"
import { createOutputChannel, onDidChangeConfiguration, registerCommand } from "./common/vscodeapi"
import { registerNxTclDebugger } from "./debugger/register"
let client: LanguageClient | undefined
export async function activate(context: vscode.ExtensionContext) {
registerNxTclDebugger(context)
// This is required to get server name and module. This should be
// the first thing that we do in this extension.
const serverInfo = loadServerDefaults()