fix(coding-agent): fixed LSP client lifecycle and DAP session robustness

clients publish only after initialize; dead readers tear down for respawn instead of permanent 30s timeouts; framing resyncs past junk headers; numeric code-action selectors pick strictly by index; file URIs percent-encode and raw fragment/query chars route to the lax parser; equal-position inserts keep spec order; workspace edits validate before writing; shutdown covers mid-init clients; reload sends notification; writethrough init deadline-bounded with negative caching; DAP pause/breakpoint races fixed, mutations serialized and abort-aware, output buffering O(n) with correct tail retention.
This commit is contained in:
can1357
2026-06-10 01:28:03 +02:00
parent d7eae06830
commit 54d4a1f3ae
10 changed files with 874 additions and 398 deletions
+125 -38
View File
@@ -29,32 +29,67 @@ type DapReverseRequestHandler = (args: unknown) => unknown | Promise<unknown>;
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
function findHeaderEnd(buffer: Uint8Array): number {
for (let index = 0; index < buffer.length - 3; index += 1) {
if (buffer[index] === 13 && buffer[index + 1] === 10 && buffer[index + 2] === 13 && buffer[index + 3] === 10) {
return index;
// Reused for all full decodes; each decode() resets state, so a single
// instance is safe and avoids per-message TextDecoder allocation.
const MESSAGE_DECODER = new TextDecoder("utf-8");
/**
* Locate the `\r\n\r\n` header terminator across the pending chunk list.
* Returns the absolute byte index of the first `\r`, or -1 when not present.
* Equivalent to scanning the contiguous concatenation of the chunks.
*/
function findHeaderEndInChunks(chunks: Buffer[]): number {
let global = 0;
let b0 = -1;
let b1 = -1;
let b2 = -1;
for (const chunk of chunks) {
for (let i = 0; i < chunk.length; i++) {
const b3 = chunk[i];
if (b0 === 13 && b1 === 10 && b2 === 13 && b3 === 10) {
return global - 3;
}
b0 = b1;
b1 = b2;
b2 = b3;
global++;
}
}
return -1;
}
function parseMessage(
buffer: Buffer,
): { message: DapResponseMessage | DapEventMessage | DapRequestMessage; remaining: Buffer } | null {
const headerEndIndex = findHeaderEnd(buffer);
if (headerEndIndex === -1) return null;
const headerText = new TextDecoder().decode(buffer.slice(0, headerEndIndex));
const contentLengthMatch = headerText.match(/Content-Length: (\d+)/i);
if (!contentLengthMatch) return null;
const contentLength = Number.parseInt(contentLengthMatch[1], 10);
const messageStart = headerEndIndex + 4;
const messageEnd = messageStart + contentLength;
if (buffer.length < messageEnd) return null;
const messageText = new TextDecoder().decode(buffer.subarray(messageStart, messageEnd));
return {
message: JSON.parse(messageText) as DapResponseMessage | DapEventMessage | DapRequestMessage,
remaining: buffer.subarray(messageEnd),
};
/** Copy the byte range [from, to) out of the pending chunk list into one Buffer. */
function copyChunkRange(chunks: Buffer[], from: number, to: number): Buffer {
const out = Buffer.allocUnsafe(to - from);
let global = 0;
let written = 0;
for (const chunk of chunks) {
const chunkEnd = global + chunk.length;
if (chunkEnd > from && global < to) {
const start = Math.max(from, global) - global;
const end = Math.min(to, chunkEnd) - global;
chunk.copy(out, written, start, end);
written += end - start;
}
global = chunkEnd;
if (global >= to) break;
}
return out;
}
/** Drop the first `count` bytes from the pending chunk list in place. */
function dropChunkFront(chunks: Buffer[], count: number): void {
let removed = 0;
while (chunks.length > 0) {
const head = chunks[0];
if (removed + head.length <= count) {
removed += head.length;
chunks.shift();
} else {
chunks[0] = head.subarray(count - removed);
break;
}
}
}
async function writeMessage(sink: DapWriteSink, message: DapRequestMessage | DapResponseMessage): Promise<void> {
@@ -81,7 +116,7 @@ export class DapClient {
readonly #socket?: { end(): void };
#requestSeq = 0;
#pendingRequests = new Map<number, DapPendingRequest>();
#messageBuffer = Buffer.alloc(0);
#messageBuffer: Buffer = Buffer.alloc(0);
#isReading = false;
#disposed = false;
#lastActivity = Date.now();
@@ -416,32 +451,84 @@ export class DapClient {
if (this.#isReading) return;
this.#isReading = true;
const reader = this.#readable.getReader();
// Incoming bytes are buffered as a list of chunks and only joined when a
// full message is framed (mirrors the LSP reader) — concatenating the
// accumulator on every read is O(n^2) for messages spanning many reads.
const pendingChunks: Buffer[] = [];
let pendingLen = 0;
if (this.#messageBuffer.length > 0) {
pendingChunks.push(this.#messageBuffer);
pendingLen = this.#messageBuffer.length;
}
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const currentBuffer = Buffer.concat([this.#messageBuffer, value]);
this.#messageBuffer = currentBuffer;
let workingBuffer = currentBuffer;
let parsed = parseMessage(workingBuffer);
while (parsed) {
const { message, remaining } = parsed;
workingBuffer = Buffer.from(remaining);
this.#lastActivity = Date.now();
if (message.type === "response") {
this.#handleResponse(message);
} else if (message.type === "event") {
await this.#dispatchEvent(message);
} else {
await this.#handleAdapterRequest(message);
pendingChunks.push(Buffer.from(value));
pendingLen += value.length;
// Drain every complete message currently buffered.
while (true) {
const headerEnd = findHeaderEndInChunks(pendingChunks);
if (headerEnd === -1) break;
const headerText = MESSAGE_DECODER.decode(copyChunkRange(pendingChunks, 0, headerEnd));
const contentLengthMatch = headerText.match(/Content-Length: (\d+)/i);
if (!contentLengthMatch) {
// Non-protocol bytes (e.g. an adapter printing to stdout).
// Drop past the bogus terminator and resync instead of
// stalling on the same junk header forever.
logger.warn("DAP framing resync: header block without Content-Length", {
adapter: this.adapter.name,
header: headerText.slice(0, 200),
});
dropChunkFront(pendingChunks, headerEnd + 4);
pendingLen -= headerEnd + 4;
continue;
}
const contentLength = Number.parseInt(contentLengthMatch[1], 10);
const messageStart = headerEnd + 4; // Skip \r\n\r\n
const messageEnd = messageStart + contentLength;
if (pendingLen < messageEnd) break;
const messageText = MESSAGE_DECODER.decode(copyChunkRange(pendingChunks, messageStart, messageEnd));
dropChunkFront(pendingChunks, messageEnd);
pendingLen -= messageEnd;
this.#lastActivity = Date.now();
// A malformed message must not kill the reader — later
// messages are still well-framed.
try {
const message = JSON.parse(messageText) as DapResponseMessage | DapEventMessage | DapRequestMessage;
if (message.type === "response") {
this.#handleResponse(message);
} else if (message.type === "event") {
await this.#dispatchEvent(message);
} else {
await this.#handleAdapterRequest(message);
}
} catch (error) {
logger.warn("DAP message handling failed", {
adapter: this.adapter.name,
error: toErrorMessage(error),
});
}
parsed = parseMessage(workingBuffer);
}
this.#messageBuffer = workingBuffer;
}
} catch (error) {
this.#rejectPendingRequests(new Error(`DAP connection closed: ${toErrorMessage(error)}`));
} finally {
// Persist any unparsed remainder so a restarted reader resumes mid-message.
this.#messageBuffer =
pendingChunks.length === 0
? Buffer.alloc(0)
: pendingChunks.length === 1
? pendingChunks[0]
: Buffer.concat(pendingChunks, pendingLen);
reader.releaseLock();
this.#isReading = false;
}
+259 -158
View File
@@ -76,8 +76,14 @@ interface DapSession {
functionBreakpoints: DapFunctionBreakpointRecord[];
instructionBreakpoints: DapInstructionBreakpoint[];
dataBreakpoints: DapDataBreakpoint[];
output: string;
/** Serializes breakpoint mutations — see #serializeBreakpointMutation. */
breakpointMutationQueue: Promise<void>;
/** Recent output chunks; trimmed from the front when over MAX_OUTPUT_BYTES. */
outputChunks: string[];
/** Cumulative bytes of output ever received (reported in summaries). */
outputBytes: number;
/** Bytes currently buffered in outputChunks. */
outputBufferedBytes: number;
outputTruncated: boolean;
stop: DapStopLocation;
threads: DapThread[];
@@ -175,10 +181,31 @@ function normalizePath(filePath: string): string {
function truncateOutput(session: DapSession, output: string): void {
if (!output) return;
session.output += output;
session.outputBytes += Buffer.byteLength(output, "utf-8");
while (Buffer.byteLength(session.output, "utf-8") > MAX_OUTPUT_BYTES) {
session.output = session.output.slice(Math.min(1024, session.output.length));
const bytes = Buffer.byteLength(output, "utf-8");
session.outputChunks.push(output);
session.outputBytes += bytes;
session.outputBufferedBytes += bytes;
// Trim whole chunks from the front, but only while the remainder still
// holds a full MAX_OUTPUT_BYTES tail — dropping the front chunk whenever
// the total exceeded the cap could retain far less than the cap (e.g.
// [120KB, 10KB] would keep only 10KB). Recomputing one big string's byte
// length per 1KB trim iteration was O(n^2) inside the event dispatch loop.
while (session.outputChunks.length > 1) {
const frontBytes = Buffer.byteLength(session.outputChunks[0], "utf-8");
if (session.outputBufferedBytes - frontBytes < MAX_OUTPUT_BYTES) break;
session.outputChunks.shift();
session.outputBufferedBytes -= frontBytes;
session.outputTruncated = true;
}
if (session.outputBufferedBytes > MAX_OUTPUT_BYTES) {
// Byte-slice the front chunk's head so exactly the cap remains (a torn
// code point at the cut decodes as U+FFFD, acceptable for log output).
const front = session.outputChunks[0];
const frontBytes = Buffer.byteLength(front, "utf-8");
const excess = session.outputBufferedBytes - MAX_OUTPUT_BYTES;
const kept = Buffer.from(front, "utf-8").subarray(excess).toString("utf-8");
session.outputChunks[0] = kept;
session.outputBufferedBytes += Buffer.byteLength(kept, "utf-8") - frontBytes;
session.outputTruncated = true;
}
}
@@ -368,6 +395,26 @@ export class DapSessionManager {
}
}
/**
* Serialize breakpoint mutations per session: every mutator does a
* read-modify-write of session state around an await, and the adapter-side
* set*Breakpoints request replaces the whole list — concurrent mutations
* would silently drop each other's breakpoints on both sides.
*/
#serializeBreakpointMutation<T>(session: DapSession, mutate: () => Promise<T>, signal?: AbortSignal): Promise<T> {
const run = session.breakpointMutationQueue.then(() => {
// A mutation can sit behind several queued 30s predecessors; honor a
// caller abort at dequeue instead of running a request nobody awaits.
if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error("Aborted");
return mutate();
});
session.breakpointMutationQueue = run.then(
() => undefined,
() => undefined,
);
return run;
}
async setBreakpoint(
file: string,
line: number,
@@ -376,99 +423,123 @@ export class DapSessionManager {
timeoutMs: number = 30_000,
) {
const session = this.#touchActiveSession();
const sourcePath = normalizePath(file);
const current = [...(session.breakpoints.get(sourcePath) ?? [])];
const deduped = current.filter(entry => entry.line !== line);
deduped.push({ verified: false, line, condition });
deduped.sort((left, right) => left.line - right.line);
const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
return this.#serializeBreakpointMutation(
session,
"setBreakpoints",
{
source: { path: sourcePath, name: path.basename(sourcePath) },
breakpoints: deduped.map<DapSourceBreakpoint>(entry => ({
line: entry.line,
...(entry.condition ? { condition: entry.condition } : {}),
})),
async () => {
const sourcePath = normalizePath(file);
const current = [...(session.breakpoints.get(sourcePath) ?? [])];
const deduped = current.filter(entry => entry.line !== line);
deduped.push({ verified: false, line, condition });
deduped.sort((left, right) => left.line - right.line);
const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
session,
"setBreakpoints",
{
source: { path: sourcePath, name: path.basename(sourcePath) },
breakpoints: deduped.map<DapSourceBreakpoint>(entry => ({
line: entry.line,
...(entry.condition ? { condition: entry.condition } : {}),
})),
},
signal,
timeoutMs,
);
session.breakpoints.set(sourcePath, this.#mapSourceBreakpoints(deduped, response?.breakpoints));
return {
snapshot: buildSummary(session),
breakpoints: session.breakpoints.get(sourcePath) ?? [],
sourcePath,
};
},
signal,
timeoutMs,
);
session.breakpoints.set(sourcePath, this.#mapSourceBreakpoints(deduped, response?.breakpoints));
return {
snapshot: buildSummary(session),
breakpoints: session.breakpoints.get(sourcePath) ?? [],
sourcePath,
};
}
async removeBreakpoint(file: string, line: number, signal?: AbortSignal, timeoutMs: number = 30_000) {
const session = this.#touchActiveSession();
const sourcePath = normalizePath(file);
const current = [...(session.breakpoints.get(sourcePath) ?? [])].filter(entry => entry.line !== line);
const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
return this.#serializeBreakpointMutation(
session,
"setBreakpoints",
{
source: { path: sourcePath, name: path.basename(sourcePath) },
breakpoints: current.map<DapSourceBreakpoint>(entry => ({
line: entry.line,
...(entry.condition ? { condition: entry.condition } : {}),
})),
async () => {
const sourcePath = normalizePath(file);
const current = [...(session.breakpoints.get(sourcePath) ?? [])].filter(entry => entry.line !== line);
const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
session,
"setBreakpoints",
{
source: { path: sourcePath, name: path.basename(sourcePath) },
breakpoints: current.map<DapSourceBreakpoint>(entry => ({
line: entry.line,
...(entry.condition ? { condition: entry.condition } : {}),
})),
},
signal,
timeoutMs,
);
if (current.length === 0) {
session.breakpoints.delete(sourcePath);
} else {
session.breakpoints.set(sourcePath, this.#mapSourceBreakpoints(current, response?.breakpoints));
}
return {
snapshot: buildSummary(session),
breakpoints: session.breakpoints.get(sourcePath) ?? [],
sourcePath,
};
},
signal,
timeoutMs,
);
if (current.length === 0) {
session.breakpoints.delete(sourcePath);
} else {
session.breakpoints.set(sourcePath, this.#mapSourceBreakpoints(current, response?.breakpoints));
}
return {
snapshot: buildSummary(session),
breakpoints: session.breakpoints.get(sourcePath) ?? [],
sourcePath,
};
}
async setFunctionBreakpoint(name: string, condition?: string, signal?: AbortSignal, timeoutMs: number = 30_000) {
const session = this.#touchActiveSession();
const current = session.functionBreakpoints.filter(entry => entry.name !== name);
current.push({ verified: false, name, condition });
current.sort((left, right) => left.name.localeCompare(right.name));
const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
return this.#serializeBreakpointMutation(
session,
"setFunctionBreakpoints",
{
breakpoints: current.map<DapFunctionBreakpoint>(entry => ({
name: entry.name,
...(entry.condition ? { condition: entry.condition } : {}),
})),
async () => {
const current = session.functionBreakpoints.filter(entry => entry.name !== name);
current.push({ verified: false, name, condition });
current.sort((left, right) => left.name.localeCompare(right.name));
const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
session,
"setFunctionBreakpoints",
{
breakpoints: current.map<DapFunctionBreakpoint>(entry => ({
name: entry.name,
...(entry.condition ? { condition: entry.condition } : {}),
})),
},
signal,
timeoutMs,
);
session.functionBreakpoints = this.#mapFunctionBreakpoints(current, response?.breakpoints);
return { snapshot: buildSummary(session), breakpoints: session.functionBreakpoints };
},
signal,
timeoutMs,
);
session.functionBreakpoints = this.#mapFunctionBreakpoints(current, response?.breakpoints);
return { snapshot: buildSummary(session), breakpoints: session.functionBreakpoints };
}
async removeFunctionBreakpoint(name: string, signal?: AbortSignal, timeoutMs: number = 30_000) {
const session = this.#touchActiveSession();
const current = session.functionBreakpoints.filter(entry => entry.name !== name);
const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
return this.#serializeBreakpointMutation(
session,
"setFunctionBreakpoints",
{
breakpoints: current.map<DapFunctionBreakpoint>(entry => ({
name: entry.name,
...(entry.condition ? { condition: entry.condition } : {}),
})),
async () => {
const current = session.functionBreakpoints.filter(entry => entry.name !== name);
const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
session,
"setFunctionBreakpoints",
{
breakpoints: current.map<DapFunctionBreakpoint>(entry => ({
name: entry.name,
...(entry.condition ? { condition: entry.condition } : {}),
})),
},
signal,
timeoutMs,
);
session.functionBreakpoints = this.#mapFunctionBreakpoints(current, response?.breakpoints);
return { snapshot: buildSummary(session), breakpoints: session.functionBreakpoints };
},
signal,
timeoutMs,
);
session.functionBreakpoints = this.#mapFunctionBreakpoints(current, response?.breakpoints);
return { snapshot: buildSummary(session), breakpoints: session.functionBreakpoints };
}
async setInstructionBreakpoint(
@@ -480,31 +551,37 @@ export class DapSessionManager {
timeoutMs: number = 30_000,
) {
const session = this.#touchActiveSession();
const current = session.instructionBreakpoints.filter(
entry => entry.instructionReference !== instructionReference || entry.offset !== offset,
);
current.push({ instructionReference, offset, condition, hitCondition });
current.sort((left, right) => {
const referenceOrder = left.instructionReference.localeCompare(right.instructionReference);
if (referenceOrder !== 0) {
return referenceOrder;
}
return (left.offset ?? 0) - (right.offset ?? 0);
});
const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
return this.#serializeBreakpointMutation(
session,
"setInstructionBreakpoints",
{
breakpoints: current,
} satisfies DapSetInstructionBreakpointsArguments,
async () => {
const current = session.instructionBreakpoints.filter(
entry => entry.instructionReference !== instructionReference || entry.offset !== offset,
);
current.push({ instructionReference, offset, condition, hitCondition });
current.sort((left, right) => {
const referenceOrder = left.instructionReference.localeCompare(right.instructionReference);
if (referenceOrder !== 0) {
return referenceOrder;
}
return (left.offset ?? 0) - (right.offset ?? 0);
});
const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
session,
"setInstructionBreakpoints",
{
breakpoints: current,
} satisfies DapSetInstructionBreakpointsArguments,
signal,
timeoutMs,
);
session.instructionBreakpoints = current;
return {
snapshot: buildSummary(session),
breakpoints: this.#mapInstructionBreakpoints(current, response?.breakpoints),
};
},
signal,
timeoutMs,
);
session.instructionBreakpoints = current;
return {
snapshot: buildSummary(session),
breakpoints: this.#mapInstructionBreakpoints(current, response?.breakpoints),
};
}
async removeInstructionBreakpoint(
@@ -514,29 +591,35 @@ export class DapSessionManager {
timeoutMs: number = 30_000,
) {
const session = this.#touchActiveSession();
const current = session.instructionBreakpoints.filter(entry => {
if (entry.instructionReference !== instructionReference) {
return true;
}
if (offset === undefined) {
return false;
}
return entry.offset !== offset;
});
const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
return this.#serializeBreakpointMutation(
session,
"setInstructionBreakpoints",
{
breakpoints: current,
} satisfies DapSetInstructionBreakpointsArguments,
async () => {
const current = session.instructionBreakpoints.filter(entry => {
if (entry.instructionReference !== instructionReference) {
return true;
}
if (offset === undefined) {
return false;
}
return entry.offset !== offset;
});
const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
session,
"setInstructionBreakpoints",
{
breakpoints: current,
} satisfies DapSetInstructionBreakpointsArguments,
signal,
timeoutMs,
);
session.instructionBreakpoints = current;
return {
snapshot: buildSummary(session),
breakpoints: this.#mapInstructionBreakpoints(current, response?.breakpoints),
};
},
signal,
timeoutMs,
);
session.instructionBreakpoints = current;
return {
snapshot: buildSummary(session),
breakpoints: this.#mapInstructionBreakpoints(current, response?.breakpoints),
};
}
async dataBreakpointInfo(
@@ -570,42 +653,54 @@ export class DapSessionManager {
timeoutMs: number = 30_000,
) {
const session = this.#touchActiveSession();
const current = session.dataBreakpoints.filter(entry => entry.dataId !== dataId);
current.push({ dataId, accessType, condition, hitCondition });
current.sort((left, right) => left.dataId.localeCompare(right.dataId));
const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
return this.#serializeBreakpointMutation(
session,
"setDataBreakpoints",
{
breakpoints: current,
} satisfies DapSetDataBreakpointsArguments,
async () => {
const current = session.dataBreakpoints.filter(entry => entry.dataId !== dataId);
current.push({ dataId, accessType, condition, hitCondition });
current.sort((left, right) => left.dataId.localeCompare(right.dataId));
const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
session,
"setDataBreakpoints",
{
breakpoints: current,
} satisfies DapSetDataBreakpointsArguments,
signal,
timeoutMs,
);
session.dataBreakpoints = current;
return {
snapshot: buildSummary(session),
breakpoints: this.#mapDataBreakpoints(current, response?.breakpoints),
};
},
signal,
timeoutMs,
);
session.dataBreakpoints = current;
return {
snapshot: buildSummary(session),
breakpoints: this.#mapDataBreakpoints(current, response?.breakpoints),
};
}
async removeDataBreakpoint(dataId: string, signal?: AbortSignal, timeoutMs: number = 30_000) {
const session = this.#touchActiveSession();
const current = session.dataBreakpoints.filter(entry => entry.dataId !== dataId);
const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
return this.#serializeBreakpointMutation(
session,
"setDataBreakpoints",
{
breakpoints: current,
} satisfies DapSetDataBreakpointsArguments,
async () => {
const current = session.dataBreakpoints.filter(entry => entry.dataId !== dataId);
const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
session,
"setDataBreakpoints",
{
breakpoints: current,
} satisfies DapSetDataBreakpointsArguments,
signal,
timeoutMs,
);
session.dataBreakpoints = current;
return {
snapshot: buildSummary(session),
breakpoints: this.#mapDataBreakpoints(current, response?.breakpoints),
};
},
signal,
timeoutMs,
);
session.dataBreakpoints = current;
return {
snapshot: buildSummary(session),
breakpoints: this.#mapDataBreakpoints(current, response?.breakpoints),
};
}
async disassemble(
@@ -756,21 +851,25 @@ export class DapSessionManager {
async pause(signal?: AbortSignal, timeoutMs: number = 30_000): Promise<DapSessionSummary> {
const session = this.#touchActiveSession();
if (session.status === "stopped") {
// status is mutated by the event reader between awaits; check through a
// closure so TS does not carry stale narrowing from the early return.
const isStopped = () => session.status === "stopped";
if (isStopped()) {
return buildSummary(session);
}
const threadId = await this.#resolveThreadId(session, signal, timeoutMs);
// Subscribe BEFORE sending pause: the stopped event can arrive in the
// same chunk as the response and would otherwise be dispatched before
// the waiter subscribes, burning the whole timeout.
const stoppedPromise = session.client.waitForEvent<DapStoppedEventBody>("stopped", undefined, signal, timeoutMs);
stoppedPromise.catch(() => {});
await this.#sendRequestWithConfig(session, "pause", { threadId } satisfies DapPauseArguments, signal, timeoutMs);
// The stopped event may already have been processed by #handleStoppedEvent
// between the request and here. Wait for it, but tolerate timeout if the
// session already transitioned.
try {
await untilAborted(
signal,
session.client.waitForEvent<DapStoppedEventBody>("stopped", undefined, signal, timeoutMs),
);
} catch {
// Timeout or abort — report current state regardless
if (!isStopped()) {
try {
await untilAborted(signal, stoppedPromise);
} catch {
// Timeout or abort — report current state regardless
}
}
return buildSummary(session);
}
@@ -884,16 +983,16 @@ export class DapSessionManager {
getOutput(limitBytes?: number): DapOutputSnapshot {
const session = this.#touchActiveSession();
if (!limitBytes || limitBytes <= 0 || Buffer.byteLength(session.output, "utf-8") <= limitBytes) {
return { snapshot: buildSummary(session), output: session.output };
const output = session.outputChunks.join("");
if (!limitBytes || limitBytes <= 0 || session.outputBufferedBytes <= limitBytes) {
return { snapshot: buildSummary(session), output };
}
let sliceStart = session.output.length;
let remaining = limitBytes;
while (sliceStart > 0 && remaining > 0) {
sliceStart -= 1;
remaining -= Buffer.byteLength(session.output[sliceStart] ?? "", "utf-8");
// Byte-slice the tail once; a torn code point at the cut decodes as U+FFFD.
const buffer = Buffer.from(output, "utf-8");
if (buffer.length <= limitBytes) {
return { snapshot: buildSummary(session), output };
}
return { snapshot: buildSummary(session), output: session.output.slice(sliceStart) };
return { snapshot: buildSummary(session), output: buffer.subarray(buffer.length - limitBytes).toString("utf-8") };
}
async terminate(signal?: AbortSignal, timeoutMs: number = 30_000): Promise<DapSessionSummary | null> {
@@ -973,8 +1072,10 @@ export class DapSessionManager {
functionBreakpoints: [],
instructionBreakpoints: [],
dataBreakpoints: [],
output: "",
breakpointMutationQueue: Promise.resolve(),
outputChunks: [],
outputBytes: 0,
outputBufferedBytes: 0,
outputTruncated: false,
stop: {},
threads: [],
+118 -38
View File
@@ -22,6 +22,10 @@ const clients = new Map<string, LspClient>();
const clientLocks = new Map<string, Promise<LspClient>>();
const fileOperationLocks = new Map<string, Promise<void>>();
/** Negative cache of recent init failures so a broken server fails fast instead of re-spawning per call. */
const INIT_FAILURE_BACKOFF_MS = 3 * 60 * 1000;
const initFailures = new Map<string, { at: number; message: string }>();
// Idle timeout configuration (disabled by default)
let idleTimeoutMs: number | null = null;
let idleCheckInterval: NodeJS.Timeout | null = null;
@@ -295,7 +299,18 @@ async function startMessageReader(client: LspClient): Promise<void> {
const headerText = MESSAGE_DECODER.decode(copyChunkRange(pendingChunks, 0, headerEnd));
const contentLengthMatch = headerText.match(/Content-Length: (\d+)/i);
if (!contentLengthMatch) break;
if (!contentLengthMatch) {
// Non-protocol bytes on stdout (e.g. a wrapper script printing).
// Drop past the bogus terminator and resync instead of stalling
// on the same junk header forever.
logger.warn("LSP framing resync: header block without Content-Length", {
server: client.name,
header: headerText.slice(0, 200),
});
dropChunkFront(pendingChunks, headerEnd + 4);
pendingLen -= headerEnd + 4;
continue;
}
const contentLength = Number.parseInt(contentLengthMatch[1], 10);
const messageStart = headerEnd + 4; // Skip \r\n\r\n
@@ -303,44 +318,54 @@ async function startMessageReader(client: LspClient): Promise<void> {
if (pendingLen < messageEnd) break;
const messageText = MESSAGE_DECODER.decode(copyChunkRange(pendingChunks, messageStart, messageEnd));
const message: LspJsonRpcResponse | LspJsonRpcNotification = JSON.parse(messageText);
dropChunkFront(pendingChunks, messageEnd);
pendingLen -= messageEnd;
// Route message
if ("id" in message && message.id !== undefined) {
// Response to a request
const pending = client.pendingRequests.get(message.id);
if (pending) {
client.pendingRequests.delete(message.id);
if ("error" in message && message.error) {
pending.reject(new Error(`LSP error: ${message.error.message}`));
} else {
pending.resolve(message.result);
// A malformed message or a throwing server-request handler must not
// kill the reader — later messages are still well-framed.
try {
const message: LspJsonRpcResponse | LspJsonRpcNotification = JSON.parse(messageText);
// Route message
if ("id" in message && message.id !== undefined) {
// Response to a request
const pending = client.pendingRequests.get(message.id);
if (pending) {
client.pendingRequests.delete(message.id);
if ("error" in message && message.error) {
pending.reject(new Error(`LSP error: ${message.error.message}`));
} else {
pending.resolve(message.result);
}
} else if ("method" in message) {
await handleServerRequest(client, message as LspJsonRpcRequest);
}
} else if ("method" in message) {
await handleServerRequest(client, message as LspJsonRpcRequest);
}
} else if ("method" in message) {
// Server notification
if (message.method === "textDocument/publishDiagnostics" && message.params) {
const params = message.params as PublishDiagnosticsParams;
client.diagnostics.set(params.uri, {
diagnostics: params.diagnostics,
version: params.version ?? null,
});
client.diagnosticsVersion += 1;
} else if (message.method === "$/progress" && message.params) {
const params = message.params as { token: string | number; value?: { kind?: string } };
if (params.value?.kind === "begin") {
client.activeProgressTokens.add(params.token);
} else if (params.value?.kind === "end") {
client.activeProgressTokens.delete(params.token);
if (client.activeProgressTokens.size === 0) {
client.resolveProjectLoaded();
// Server notification
if (message.method === "textDocument/publishDiagnostics" && message.params) {
const params = message.params as PublishDiagnosticsParams;
client.diagnostics.set(params.uri, {
diagnostics: params.diagnostics,
version: params.version ?? null,
});
client.diagnosticsVersion += 1;
} else if (message.method === "$/progress" && message.params) {
const params = message.params as { token: string | number; value?: { kind?: string } };
if (params.value?.kind === "begin") {
client.activeProgressTokens.add(params.token);
} else if (params.value?.kind === "end") {
client.activeProgressTokens.delete(params.token);
if (client.activeProgressTokens.size === 0) {
client.resolveProjectLoaded();
}
}
}
}
} catch (err) {
logger.warn("LSP message handling failed", {
server: client.name,
error: err instanceof Error ? err.message : String(err),
});
}
}
}
@@ -360,6 +385,22 @@ async function startMessageReader(client: LspClient): Promise<void> {
: Buffer.concat(pendingChunks, pendingLen);
reader.releaseLock();
client.isReading = false;
// Reader exited while the server process is still alive (unrecoverable
// read error or bad stream state): nothing will route responses anymore,
// so tear the client down — the next call respawns instead of timing out.
if (client.proc.exitCode === null) {
client.status = "error";
if (clients.get(client.name) === client) {
clients.delete(client.name);
}
const teardownErr = new Error("LSP reader stopped; client torn down");
for (const pending of client.pendingRequests.values()) {
pending.reject(teardownErr);
}
client.pendingRequests.clear();
client.resolveProjectLoaded();
client.proc.kill();
}
}
}
@@ -565,6 +606,16 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
return existingLock;
}
// Fail fast on a recent deterministic init failure instead of re-spawning
// a broken server (and paying its full init wait) on every call.
const recentFailure = initFailures.get(key);
if (recentFailure) {
if (Date.now() - recentFailure.at < INIT_FAILURE_BACKOFF_MS) {
throw new Error(`LSP server ${config.command} failed to initialize recently: ${recentFailure.message}`);
}
initFailures.delete(key);
}
// Create new client with lock
const clientPromise = (async () => {
const baseCommand = config.resolvedCommand ?? config.command;
@@ -605,18 +656,18 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
pendingRequests: new Map(),
messageBuffer: new Uint8Array(0),
isReading: false,
status: "connecting",
lastActivity: Date.now(),
writeQueue: Promise.resolve(),
activeProgressTokens: new Set(),
projectLoaded,
resolveProjectLoaded,
};
clients.set(key, client);
// Register crash recovery - remove client on process exit
proc.exited.then(() => {
clients.delete(key);
clientLocks.delete(key);
if (clients.get(key) === client) clients.delete(key);
if (clientLocks.get(key) === clientPromise) clientLocks.delete(key);
client.resolveProjectLoaded();
// Reject any pending requests — the server is gone, they will never complete.
@@ -669,12 +720,26 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
// Send initialized notification
await sendNotification(client, "initialized", {});
client.status = "ready";
// Publish only after init succeeds: pre-init clients are reachable
// solely through clientLocks, so concurrent callers (warmup vs first
// tool call) wait for init instead of using an unacknowledged client.
clients.set(key, client);
initFailures.delete(key);
return client;
} catch (err) {
// Clean up on initialization failure
clients.delete(key);
clientLocks.delete(key);
client.status = "error";
if (clients.get(key) === client) clients.delete(key);
proc.kill();
const message = err instanceof Error ? err.message : String(err);
// Negative-cache deterministic failures. Timeouts under a
// caller-shortened deadline (warmup/writethrough) are not cached —
// the server may simply be slow and a later call with the full
// deadline can still succeed.
if (!(initTimeoutMs !== undefined && message.includes("timed out"))) {
initFailures.set(key, { at: Date.now(), message });
}
throw err;
} finally {
clientLocks.delete(key);
@@ -1067,7 +1132,22 @@ export async function sendNotification(client: LspClient, method: string, params
export async function shutdownAll(): Promise<void> {
const clientsToShutdown = Array.from(clients.values());
clients.clear();
await Promise.allSettled(clientsToShutdown.map(client => shutdownClientInstance(client)));
// Mid-initialize clients live only in clientLocks (publication is deferred
// until init succeeds) — without this, their server processes outlive
// shutdown. Failed init promises already cleaned up after themselves.
const pendingClients = Array.from(clientLocks.values());
clientLocks.clear();
const seen = new Set<LspClient>(clientsToShutdown);
await Promise.allSettled([
...clientsToShutdown.map(client => shutdownClientInstance(client)),
...pendingClients.map(pending =>
pending.then(client => {
if (seen.has(client)) return;
seen.add(client);
return shutdownClientInstance(client);
}),
),
]);
}
/** Status of an LSP server */
@@ -1084,7 +1164,7 @@ export interface LspServerStatus {
export function getActiveClients(): LspServerStatus[] {
return Array.from(clients.values()).map(client => ({
name: client.config.command,
status: "ready" as const,
status: client.status,
fileTypes: client.config.fileTypes,
}));
}
@@ -3,6 +3,7 @@
* Uses Biome's CLI with JSON output instead of LSP (which has stale diagnostics issues).
*/
import path from "node:path";
import { logger } from "@oh-my-pi/pi-utils";
import type { Diagnostic, DiagnosticSeverity, LinterClient, ServerConfig } from "../../lsp/types";
// =============================================================================
@@ -29,17 +30,23 @@ interface BiomeDiagnostic {
// =============================================================================
/**
* Convert byte offset to line:column using source code.
* Convert byte offsets to line:column positions in a single pass over the source.
*/
function offsetToPosition(source: string, offset: number): { line: number; column: number } {
function offsetsToPositions(source: string, offsets: number[]): Map<number, { line: number; column: number }> {
const sorted = [...new Set(offsets)].sort((a, b) => a - b);
const result = new Map<number, { line: number; column: number }>();
let line = 1;
let column = 1;
let byteIndex = 0;
let next = 0;
for (const ch of source) {
const byteLen = Buffer.byteLength(ch);
if (byteIndex + byteLen > offset) {
break;
if (next >= sorted.length) break;
const cp = ch.codePointAt(0) as number;
const byteLen = cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
while (next < sorted.length && byteIndex + byteLen > sorted[next]) {
result.set(sorted[next], { line, column });
next++;
}
if (ch === "\n") {
line++;
@@ -50,7 +57,13 @@ function offsetToPosition(source: string, offset: number): { line: number; colum
byteIndex += byteLen;
}
return { line, column };
// Offsets at or past end-of-file map to the final position.
while (next < sorted.length) {
result.set(sorted[next], { line, column });
next++;
}
return result;
}
/**
@@ -98,6 +111,16 @@ async function runBiome(
}
}
// Surface broken-binary / CLI failures once instead of silently reporting
// "no diagnostics" forever (and instead of spamming every writethrough).
const reportedBiomeFailures = new Set<string>();
function warnBiomeOnce(key: string, message: string, meta: Record<string, unknown>): void {
if (reportedBiomeFailures.has(key)) return;
reportedBiomeFailures.add(key);
logger.warn(message, meta);
}
// =============================================================================
// Biome Client
// =============================================================================
@@ -137,6 +160,16 @@ export class BiomeClient implements LinterClient {
// Run biome lint with JSON reporter
const result = await runBiome(["lint", "--reporter=json", filePath], this.cwd, this.config.resolvedCommand);
// Biome exits non-zero when diagnostics are found, so only an empty
// stdout signals an actual run failure (missing binary, CLI error).
if (!result.success && result.stdout.trim().length === 0) {
warnBiomeOnce(`run:${this.cwd}`, "Biome lint failed; reporting no diagnostics", {
cwd: this.cwd,
stderr: result.stderr.slice(0, 500),
});
return [];
}
return this.#parseJsonOutput(result.stdout, filePath);
}
@@ -146,51 +179,80 @@ export class BiomeClient implements LinterClient {
#parseJsonOutput(jsonOutput: string, targetFile: string): Diagnostic[] {
const diagnostics: Diagnostic[] = [];
let parsed: BiomeJsonOutput;
try {
const parsed: BiomeJsonOutput = JSON.parse(jsonOutput);
parsed = JSON.parse(jsonOutput);
} catch {
warnBiomeOnce(`parse:${this.cwd}`, "Failed to parse Biome JSON output; reporting no diagnostics", {
cwd: this.cwd,
file: targetFile,
});
return diagnostics;
}
for (const diag of parsed.diagnostics) {
const location = diag.location;
if (!location?.path?.file) continue;
const target = path.resolve(targetFile);
const relevant: BiomeDiagnostic[] = [];
// Batch all span offsets per source text so each source is scanned once
// instead of twice per diagnostic.
const offsetsBySource = new Map<string, number[]>();
for (const diag of parsed.diagnostics ?? []) {
const location = diag.location;
if (!location?.path?.file) continue;
// Resolve file path
const diagFile = path.isAbsolute(location.path.file)
? location.path.file
: path.join(this.cwd, location.path.file);
// Resolve file path
const diagFile = path.isAbsolute(location.path.file)
? location.path.file
: path.join(this.cwd, location.path.file);
// Only include diagnostics for the target file
if (path.resolve(diagFile) !== path.resolve(targetFile)) {
continue;
}
// Only include diagnostics for the target file
if (path.resolve(diagFile) !== target) {
continue;
}
// Convert byte offset to line:column
let startLine = 1;
let startColumn = 1;
let endLine = 1;
let endColumn = 1;
relevant.push(diag);
if (location.span && location.sourceCode) {
const offsets = offsetsBySource.get(location.sourceCode);
if (offsets) offsets.push(location.span[0], location.span[1]);
else offsetsBySource.set(location.sourceCode, [location.span[0], location.span[1]]);
}
}
if (location.span && location.sourceCode) {
const startPos = offsetToPosition(location.sourceCode, location.span[0]);
const endPos = offsetToPosition(location.sourceCode, location.span[1]);
const positionsBySource = new Map<string, Map<number, { line: number; column: number }>>();
for (const [source, offsets] of offsetsBySource) {
positionsBySource.set(source, offsetsToPositions(source, offsets));
}
for (const diag of relevant) {
const location = diag.location;
let startLine = 1;
let startColumn = 1;
let endLine = 1;
let endColumn = 1;
if (location?.span && location.sourceCode) {
const positions = positionsBySource.get(location.sourceCode);
const startPos = positions?.get(location.span[0]);
const endPos = positions?.get(location.span[1]);
if (startPos) {
startLine = startPos.line;
startColumn = startPos.column;
}
if (endPos) {
endLine = endPos.line;
endColumn = endPos.column;
}
diagnostics.push({
range: {
start: { line: startLine - 1, character: startColumn - 1 },
end: { line: endLine - 1, character: endColumn - 1 },
},
severity: parseSeverity(diag.severity),
message: diag.description,
source: "biome",
code: diag.category,
});
}
} catch {
// JSON parse failed, return empty
diagnostics.push({
range: {
start: { line: startLine - 1, character: startColumn - 1 },
end: { line: endLine - 1, character: endColumn - 1 },
},
severity: parseSeverity(diag.severity),
message: diag.description,
source: "biome",
code: diag.category,
});
}
return diagnostics;
+143 -95
View File
@@ -24,27 +24,7 @@ import { uriToFile } from "./utils";
*/
export function applyTextEditsToString(content: string, edits: TextEdit[]): string {
const lines = content.split("\n");
// Sort edits in reverse order (bottom-to-top, right-to-left)
const sortedEdits = [...edits].sort((a, b) => {
if (a.range.start.line !== b.range.start.line) {
return b.range.start.line - a.range.start.line;
}
return b.range.start.character - a.range.start.character;
});
// Detect overlapping ranges: in reverse-sorted order, each edit's start
// must be >= the next edit's end. If not, the edits would clobber each other
// once applied bottom-up (typically a multi-server rename with stale positions).
for (let i = 0; i < sortedEdits.length - 1; i++) {
const later = sortedEdits[i].range;
const earlier = sortedEdits[i + 1].range;
if (comparePosition(earlier.end, later.start) > 0) {
throw new ToolError(
`overlapping LSP edits: ${formatRange(earlier)} conflicts with ${formatRange(later)}; multi-server rename produced inconsistent edits`,
);
}
}
const sortedEdits = sortAndValidateTextEdits(edits);
for (const edit of sortedEdits) {
const { start, end } = edit.range;
@@ -78,6 +58,42 @@ export function rangesOverlap(a: Range, b: Range): boolean {
return comparePosition(a.start, b.end) < 0 && comparePosition(b.start, a.end) < 0;
}
/**
* Sort edits bottom-to-top for in-place application and reject overlaps.
* Equal start positions tiebreak by original array index descending so that,
* applied bottom-up, inserts at the same position land in array order
* (LSP spec: the order of edits in the array defines the order in the result).
*/
export function sortAndValidateTextEdits(edits: TextEdit[]): TextEdit[] {
const sorted = edits
.map((edit, index) => ({ edit, index }))
.sort((a, b) => {
if (a.edit.range.start.line !== b.edit.range.start.line) {
return b.edit.range.start.line - a.edit.range.start.line;
}
if (a.edit.range.start.character !== b.edit.range.start.character) {
return b.edit.range.start.character - a.edit.range.start.character;
}
return b.index - a.index;
})
.map(entry => entry.edit);
// Detect overlapping ranges: in reverse-sorted order, each edit's start
// must be >= the next edit's end. If not, the edits would clobber each other
// once applied bottom-up (typically a multi-server rename with stale positions).
for (let i = 0; i < sorted.length - 1; i++) {
const later = sorted[i].range;
const earlier = sorted[i + 1].range;
if (comparePosition(earlier.end, later.start) > 0) {
throw new ToolError(
`overlapping LSP edits: ${formatRange(earlier)} conflicts with ${formatRange(later)}; multi-server rename produced inconsistent edits`,
);
}
}
return sorted;
}
/**
* Flatten a WorkspaceEdit's text edits into a Map<uri, TextEdit[]>.
* Resource operations (create/rename/delete) are ignored — callers handle them separately.
@@ -120,92 +136,124 @@ export async function applyTextEdits(filePath: string, edits: TextEdit[]): Promi
// Workspace Edit Application
// =============================================================================
type WorkspaceEditOp =
| { kind: "text"; uri: string; edits: TextEdit[] }
| { kind: "create"; uri: string }
| { kind: "rename"; oldUri: string; newUri: string }
| { kind: "delete"; uri: string };
/**
* Flatten documentChanges into an ordered op list. Text edits are accumulated
* per-URI and flushed before any resource op that touches the same URI (or,
* for folder rename/delete, any descendant URI) so that renames, creates, and
* deletes always see the correct prior file state.
*/
function planDocumentChanges(documentChanges: NonNullable<WorkspaceEdit["documentChanges"]>): WorkspaceEditOp[] {
const ops: WorkspaceEditOp[] = [];
const pending = new Map<string, TextEdit[]>();
const flushUri = (uri: string) => {
const edits = pending.get(uri);
if (!edits) return;
pending.delete(uri);
ops.push({ kind: "text", uri, edits });
};
// Flush the exact URI plus every pending descendant (for folder-level
// resource ops where the queued edits target child files of the target).
const flushSubtree = (uri: string) => {
const prefix = uri.endsWith("/") ? uri : `${uri}/`;
const matches: string[] = [];
for (const candidate of pending.keys()) {
if (candidate === uri || candidate.startsWith(prefix)) matches.push(candidate);
}
for (const target of matches) {
flushUri(target);
}
};
for (const change of documentChanges) {
if ("textDocument" in change && change.textDocument && "edits" in change && change.edits) {
const tdc = change as TextDocumentEdit;
const uri = tdc.textDocument.uri;
const textEdits = tdc.edits.filter((e): e is TextEdit => "range" in e && "newText" in e);
if (textEdits.length > 0) {
const prev = pending.get(uri);
if (prev) prev.push(...textEdits);
else pending.set(uri, [...textEdits]);
}
} else if ("kind" in change && change.kind) {
if (change.kind === "create") {
const createOp = change as CreateFile;
flushUri(createOp.uri);
ops.push({ kind: "create", uri: createOp.uri });
} else if (change.kind === "rename") {
const renameOp = change as RenameFile;
// Per LSP §3.16.2 documentChanges are applied in declared order.
// Flush both the source subtree (so prior edits land before the move)
// AND the destination subtree (so prior edits land on whatever exists
// at newUri before the rename overwrites/replaces it — relevant under
// `options.overwrite` and `options.ignoreIfExists`).
flushSubtree(renameOp.oldUri);
flushSubtree(renameOp.newUri);
ops.push({ kind: "rename", oldUri: renameOp.oldUri, newUri: renameOp.newUri });
} else if (change.kind === "delete") {
const deleteOp = change as DeleteFile;
flushSubtree(deleteOp.uri);
ops.push({ kind: "delete", uri: deleteOp.uri });
}
}
}
// Flush text edits not followed by a resource op.
for (const uri of [...pending.keys()]) {
flushUri(uri);
}
return ops;
}
/**
* Apply a workspace edit (collection of file changes).
* All text-edit batches are overlap-validated before anything is written so a
* conflict throws without leaving the workspace half-applied.
* Returns array of applied change descriptions.
*/
export async function applyWorkspaceEdit(edit: WorkspaceEdit, cwd: string): Promise<string[]> {
const applied: string[] = [];
if (edit.documentChanges) {
// Walk documentChanges in original order. Accumulate text edits per-URI and
// flush them before any resource op that touches the same URI (or, for folder
// rename/delete, any descendant URI) so that renames, creates, and deletes
// always see the correct prior file state.
const pending = new Map<string, TextEdit[]>();
const flushUri = async (uri: string) => {
const edits = pending.get(uri);
if (!edits) return;
pending.delete(uri);
const filePath = uriToFile(uri);
await applyTextEdits(filePath, edits);
applied.push(`Applied ${edits.length} edit(s) to ${formatPathRelativeToCwd(filePath, cwd)}`);
};
// Flush the exact URI plus every pending descendant (for folder-level
// resource ops where the queued edits target child files of the target).
const flushSubtree = async (uri: string) => {
const prefix = uri.endsWith("/") ? uri : `${uri}/`;
const matches: string[] = [];
for (const candidate of pending.keys()) {
if (candidate === uri || candidate.startsWith(prefix)) matches.push(candidate);
}
for (const target of matches) {
await flushUri(target);
}
};
for (const change of edit.documentChanges) {
if ("textDocument" in change && change.textDocument && "edits" in change && change.edits) {
const tdc = change as TextDocumentEdit;
const uri = tdc.textDocument.uri;
const textEdits = tdc.edits.filter((e): e is TextEdit => "range" in e && "newText" in e);
if (textEdits.length > 0) {
const prev = pending.get(uri);
if (prev) prev.push(...textEdits);
else pending.set(uri, [...textEdits]);
}
} else if ("kind" in change && change.kind) {
if (change.kind === "create") {
const createOp = change as CreateFile;
await flushUri(createOp.uri);
const filePath = uriToFile(createOp.uri);
await Bun.write(filePath, "");
applied.push(`Created ${formatPathRelativeToCwd(filePath, cwd)}`);
} else if (change.kind === "rename") {
const renameOp = change as RenameFile;
// Per LSP §3.16.2 documentChanges are applied in declared order.
// Flush both the source subtree (so prior edits land before the move)
// AND the destination subtree (so prior edits land on whatever exists
// at newUri before the rename overwrites/replaces it — relevant under
// `options.overwrite` and `options.ignoreIfExists`).
await flushSubtree(renameOp.oldUri);
await flushSubtree(renameOp.newUri);
const oldPath = uriToFile(renameOp.oldUri);
const newPath = uriToFile(renameOp.newUri);
await fs.mkdir(path.dirname(newPath), { recursive: true });
await fs.rename(oldPath, newPath);
applied.push(
`Renamed ${formatPathRelativeToCwd(oldPath, cwd)} → ${formatPathRelativeToCwd(newPath, cwd)}`,
);
} else if (change.kind === "delete") {
const deleteOp = change as DeleteFile;
await flushSubtree(deleteOp.uri);
const filePath = uriToFile(deleteOp.uri);
await fs.rm(filePath, { recursive: true });
applied.push(`Deleted ${formatPathRelativeToCwd(filePath, cwd)}`);
}
}
const ops = planDocumentChanges(edit.documentChanges);
for (const op of ops) {
if (op.kind === "text") sortAndValidateTextEdits(op.edits);
}
// Flush text edits not followed by a resource op.
for (const [uri] of pending) {
await flushUri(uri);
for (const op of ops) {
if (op.kind === "text") {
const filePath = uriToFile(op.uri);
await applyTextEdits(filePath, op.edits);
applied.push(`Applied ${op.edits.length} edit(s) to ${formatPathRelativeToCwd(filePath, cwd)}`);
} else if (op.kind === "create") {
const filePath = uriToFile(op.uri);
await Bun.write(filePath, "");
applied.push(`Created ${formatPathRelativeToCwd(filePath, cwd)}`);
} else if (op.kind === "rename") {
const oldPath = uriToFile(op.oldUri);
const newPath = uriToFile(op.newUri);
await fs.mkdir(path.dirname(newPath), { recursive: true });
await fs.rename(oldPath, newPath);
applied.push(`Renamed ${formatPathRelativeToCwd(oldPath, cwd)} → ${formatPathRelativeToCwd(newPath, cwd)}`);
} else {
const filePath = uriToFile(op.uri);
await fs.rm(filePath, { recursive: true });
applied.push(`Deleted ${formatPathRelativeToCwd(filePath, cwd)}`);
}
}
} else if (edit.changes) {
// Legacy changes-map path: apply all text edits in one pass.
// Legacy changes-map path: validate every file's edits before writing any.
const changes = edit.changes;
for (const uri in changes) {
sortAndValidateTextEdits(changes[uri]);
}
for (const uri in changes) {
const textEdits = changes[uri];
if (textEdits.length === 0) continue;
+25 -19
View File
@@ -454,21 +454,23 @@ function isMethodNotFoundError(err: unknown): boolean {
}
async function reloadServer(client: LspClient, serverName: string, signal?: AbortSignal): Promise<string> {
let output = `Restarted ${serverName}`;
const reloadMethods = ["rust-analyzer/reloadWorkspace", "workspace/didChangeConfiguration"];
for (const method of reloadMethods) {
try {
await sendRequest(client, method, method.includes("Configuration") ? { settings: {} } : null, signal);
output = `Reloaded ${serverName}`;
break;
} catch {
// Method not supported, try next
}
// rust-analyzer exposes a real reload request.
try {
await sendRequest(client, "rust-analyzer/reloadWorkspace", null, signal);
return `Reloaded ${serverName}`;
} catch {
// Method not supported — fall through.
}
if (output.startsWith("Restarted")) {
// workspace/didChangeConfiguration is a notification per spec; sending it
// as a request hangs until the tool deadline on servers that route it to
// the notification handler and never respond.
try {
await sendNotification(client, "workspace/didChangeConfiguration", { settings: {} });
return `Reloaded ${serverName}`;
} catch {
client.proc.kill();
return `Restarted ${serverName}`;
}
return output;
}
interface WaitForDiagnosticsOptions {
@@ -636,12 +638,13 @@ interface GetDiagnosticsForFileOptions {
async function captureDiagnosticVersions(
cwd: string,
servers: Array<[string, ServerConfig]>,
initTimeoutMs?: number,
): Promise<ServerVersionMap> {
const versions = new Map<string, number>();
await Promise.allSettled(
servers.map(async ([serverName, serverConfig]) => {
if (serverConfig.createClient) return;
const client = await getOrCreateClient(serverConfig, cwd);
const client = await getOrCreateClient(serverConfig, cwd, initTimeoutMs);
versions.set(serverName, client.diagnosticsVersion);
}),
);
@@ -1118,7 +1121,9 @@ async function runLspWritethrough(
const useCustomFormatter = enableFormat && customLinterServers.length > 0;
// Capture diagnostic versions BEFORE syncing to detect stale diagnostics
const minVersions = enableDiagnostics ? await captureDiagnosticVersions(cwd, servers) : undefined;
// Bound client creation by the writethrough budget: a hung/broken server
// must not add its full init wait (30s default) to every edit.
const minVersions = enableDiagnostics ? await captureDiagnosticVersions(cwd, servers, 5_000) : undefined;
let expectedDocumentVersions: ServerVersionMap | undefined;
let formatter: FileFormatResult | undefined;
@@ -2311,11 +2316,12 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
break;
}
const parsedIndex = /^\d+$/.test(normalizedQuery) ? Number.parseInt(normalizedQuery, 10) : null;
const selectedAction = result.find(
(actionItem, index) =>
(parsedIndex !== null && index === parsedIndex) ||
actionItem.title.toLowerCase().includes(normalizedQuery.toLowerCase()),
);
const selectedAction =
parsedIndex !== null
? result[parsedIndex]
: result.find(actionItem =>
actionItem.title.toLowerCase().includes(normalizedQuery.toLowerCase()),
);
if (!selectedAction) {
const actionLines = result.map((actionItem, index) => ` ${formatCodeAction(actionItem, index)}`);
+2
View File
@@ -416,6 +416,8 @@ export interface LspClient {
pendingRequests: Map<number, PendingRequest>;
messageBuffer: Uint8Array;
isReading: boolean;
/** Lifecycle state: "connecting" until initialize completes, then "ready"; "error" on init failure or reader death. */
status: "connecting" | "ready" | "error";
serverCapabilities?: LspServerCapabilities;
lastActivity: number;
/** Serializes outbound JSON-RPC writes to the server process. */
+28 -10
View File
@@ -27,22 +27,17 @@ export { detectLanguageId } from "../utils/lang-from-path";
/**
* Convert a file path to a file:// URI.
* Uses the URL machinery so special characters (`%`, `#`, `?`, spaces) are
* percent-encoded; plain concatenation produced URIs that broke round-trips.
* Handles Windows drive letters correctly.
*/
export function fileToUri(filePath: string): string {
const resolved = path.resolve(filePath);
if (process.platform === "win32") {
// Windows: file:///C:/path/to/file
return `file:///${resolved.replace(/\\/g, "/")}`;
}
// Unix: file:///path/to/file
return `file://${resolved}`;
return Bun.pathToFileURL(path.resolve(filePath)).href;
}
/**
* Convert a file:// URI to a file path.
* Tolerates both percent-encoded URIs and lax servers that send raw paths.
* Handles Windows drive letters correctly.
*/
export function uriToFile(uri: string): string {
@@ -50,7 +45,30 @@ export function uriToFile(uri: string): string {
return uri;
}
let filePath = decodeURIComponent(uri.slice(7));
// A raw `#`/`?` parses *successfully* as fragment/query and silently
// truncates the path — it never reaches the catch below. LSP servers do
// not use fragments or queries on file URIs (encoded forms are %23/%3F),
// so raw occurrences mean a lax server sent an unencoded path.
if (uri.includes("#") || uri.includes("?")) {
return laxUriToFile(uri);
}
try {
return Bun.fileURLToPath(uri);
} catch {
// Not a well-formed file URL (unencoded characters, stray `%`, host
// component). Fall back to a lenient manual conversion.
return laxUriToFile(uri);
}
}
function laxUriToFile(uri: string): string {
let filePath = uri.slice(7);
try {
filePath = decodeURIComponent(filePath);
} catch {
// Invalid percent-encoding — treat as a literal path.
}
// Windows: file:///C:/path → C:/path (strip leading slash before drive letter)
if (process.platform === "win32" && filePath.startsWith("/") && /^[A-Za-z]:/.test(filePath.slice(1))) {
@@ -37,6 +37,7 @@ function createClient(cwd: string, config: ServerConfig): LspClient {
pendingRequests: new Map(),
messageBuffer: new Uint8Array(),
isReading: false,
status: "ready",
lastActivity: Date.now(),
writeQueue: Promise.resolve(),
activeProgressTokens: new Set(),
@@ -7,7 +7,7 @@ import { LspTool } from "@oh-my-pi/pi-coding-agent/lsp";
import * as lspClient from "@oh-my-pi/pi-coding-agent/lsp/client";
import * as lspConfig from "@oh-my-pi/pi-coding-agent/lsp/config";
import { getServersForFile, loadConfig } from "@oh-my-pi/pi-coding-agent/lsp/config";
import { applyWorkspaceEdit } from "@oh-my-pi/pi-coding-agent/lsp/edits";
import { applyTextEditsToString, applyWorkspaceEdit } from "@oh-my-pi/pi-coding-agent/lsp/edits";
import { renderCall, renderResult } from "@oh-my-pi/pi-coding-agent/lsp/render";
import type {
CodeAction,
@@ -31,6 +31,7 @@ import {
hasGlobPattern,
resolveDiagnosticTargets,
resolveSymbolColumn,
uriToFile,
} from "@oh-my-pi/pi-coding-agent/lsp/utils";
import { getThemeByName } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
@@ -799,6 +800,7 @@ for await (const chunk of Bun.stdin.stream()) {
pendingRequests: new Map(),
messageBuffer: new Uint8Array(),
isReading: false,
status: "ready",
lastActivity: Date.now(),
writeQueue: Promise.resolve(),
activeProgressTokens: new Set(),
@@ -1002,6 +1004,7 @@ for await (const chunk of Bun.stdin.stream()) {
pendingRequests: new Map(),
messageBuffer: new Uint8Array(),
isReading: false,
status: "ready",
lastActivity: Date.now(),
writeQueue: Promise.resolve(),
activeProgressTokens: new Set(),
@@ -1103,6 +1106,7 @@ for await (const chunk of Bun.stdin.stream()) {
pendingRequests: new Map(),
messageBuffer: new Uint8Array(),
isReading: false,
status: "ready",
lastActivity: Date.now(),
writeQueue: Promise.resolve(),
activeProgressTokens: new Set(),
@@ -1163,6 +1167,7 @@ for await (const chunk of Bun.stdin.stream()) {
pendingRequests: new Map(),
messageBuffer: new Uint8Array(),
isReading: false,
status: "ready",
lastActivity: Date.now(),
writeQueue: Promise.resolve(),
activeProgressTokens: new Set(),
@@ -1232,6 +1237,7 @@ for await (const chunk of Bun.stdin.stream()) {
pendingRequests: new Map(),
messageBuffer: new Uint8Array(),
isReading: false,
status: "ready",
lastActivity: Date.now(),
writeQueue: Promise.resolve(),
activeProgressTokens: new Set(),
@@ -1301,6 +1307,7 @@ for await (const chunk of Bun.stdin.stream()) {
pendingRequests: new Map(),
messageBuffer: new Uint8Array(),
isReading: false,
status: "ready",
lastActivity: Date.now(),
writeQueue: Promise.resolve(),
activeProgressTokens: new Set(),
@@ -1358,6 +1365,7 @@ for await (const chunk of Bun.stdin.stream()) {
pendingRequests: new Map(),
messageBuffer: new Uint8Array(),
isReading: false,
status: "ready",
lastActivity: Date.now(),
writeQueue: Promise.resolve(),
activeProgressTokens: new Set(),
@@ -1506,6 +1514,67 @@ for await (const chunk of Bun.stdin.stream()) {
tempDir.removeSync();
}
});
it("applies equal-position inserts in array order", () => {
// LSP spec: multiple inserts at the same position land in the order they
// appear in the edits array (import + reference insertions rely on this).
const result = applyTextEditsToString("abc", [
{ range: { start: { line: 0, character: 1 }, end: { line: 0, character: 1 } }, newText: "X" },
{ range: { start: { line: 0, character: 1 }, end: { line: 0, character: 1 } }, newText: "Y" },
]);
expect(result).toBe("aXYbc");
});
it("validates every file's edits before writing any workspace-edit file", async () => {
const tempDir = TempDir.createSync("@omp-lsp-atomic-validate-");
try {
const okPath = path.join(tempDir.path(), "ok.ts");
const badPath = path.join(tempDir.path(), "bad.ts");
const okContent = "export const ok = 1;\n";
await Bun.write(okPath, okContent);
await Bun.write(badPath, "export const bad = 2;\n");
const workspaceEdit: WorkspaceEdit = {
changes: {
[fileToUri(okPath)]: [
{
range: { start: { line: 0, character: 13 }, end: { line: 0, character: 15 } },
newText: "changed",
},
],
[fileToUri(badPath)]: [
// Overlapping edits — must reject the whole workspace edit.
{
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 10 } },
newText: "x",
},
{
range: { start: { line: 0, character: 5 }, end: { line: 0, character: 12 } },
newText: "y",
},
],
},
};
await expect(applyWorkspaceEdit(workspaceEdit, tempDir.path())).rejects.toThrow(/overlapping LSP edits/);
// The valid file must be untouched: validation runs before any write.
expect(fs.readFileSync(okPath, "utf8")).toBe(okContent);
} finally {
tempDir.removeSync();
}
});
it("round-trips file URIs containing percent and hash characters", () => {
const tricky = path.join("/tmp", "omp uri", "100% #1.ts");
const uri = fileToUri(tricky);
// Percent-encoded so the server cannot misparse a fragment or escape.
expect(uri).not.toContain("#");
expect(uri).not.toContain(" ");
expect(uriToFile(uri)).toBe(tricky);
// Lax servers sending unencoded paths are tolerated.
expect(uriToFile("file:///tmp/omp uri/plain.ts")).toBe("/tmp/omp uri/plain.ts");
});
it("resolves $-prefixed identifiers past compound matches", async () => {
// Pre-fix, BARE_IDENTIFIER_RE rejected leading `$`, so requireWordBoundary
// was false and `resolveSymbolColumn(_, _, "$store")` returned the column
@@ -1645,6 +1714,7 @@ for await (const chunk of Bun.stdin.stream()) {
pendingRequests: new Map(),
messageBuffer: new Uint8Array(),
isReading: false,
status: "ready",
lastActivity: Date.now(),
writeQueue: Promise.resolve(),
activeProgressTokens: new Set(),
@@ -1670,6 +1740,7 @@ for await (const chunk of Bun.stdin.stream()) {
pendingRequests: new Map(),
messageBuffer: new Uint8Array(),
isReading: false,
status: "ready",
lastActivity: Date.now(),
writeQueue: Promise.resolve(),
activeProgressTokens: new Set(),