fix(lsp): address codex review round 1
Lifecycle and safety fixes from the external review: - Observe abort while awaiting the initialize handshake, so a server that never replies can't defeat the tool-timeout signal. - On an aborted request the server won't cancel, tear the instance down after a bounded grace instead of releasing the serialized queue with work still live (prevents overlapping document lifecycles). - Re-check provider disposal after the canonicalize/read awaits so a query can't spawn an unowned server after disposeAll(). - Read the source through one open handle (stat + read on the same fd) to close the realpath-vs-read TOCTOU; decode with a fatal UTF-8 decoder so a legitimate U+FFFD is not misclassified as invalid. - Validate and read the source BEFORE spawning a server (pre-start rejection). - Require an explicit openClose for option-form textDocumentSync. - Reject nonpositive teardown budgets and non-executable absolute commands at load; surface unsupported operations as structured LSP_UNSUPPORTED_OPERATION. - Retain the stderr tail (fatal diagnostics land at exit), not the prefix. - Catalog the seam vocabulary in docs/core-data-structures/lsp.md.
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
* @module @deepseek-ai/dsh-lsp-local/host
|
||||
*/
|
||||
|
||||
import { readFile, realpath, stat } from 'node:fs/promises'
|
||||
import { open, realpath, stat } from 'node:fs/promises'
|
||||
import { isAbsolute, resolve as resolvePath, sep } from 'node:path'
|
||||
|
||||
/** A validated source: its canonical absolute path and current UTF-8 text. */
|
||||
@@ -68,16 +68,24 @@ export async function readHostSource(
|
||||
if (!isInside(canonicalWorkspace, canonicalPath)) {
|
||||
throw new Error(`source "${filePath}" resolves outside the workspace`)
|
||||
}
|
||||
const info = await stat(canonicalPath)
|
||||
if (!info.isFile()) {
|
||||
throw new Error(`source "${filePath}" is not a regular file`)
|
||||
// Open ONE handle after containment, then stat and read through it: a concurrent replace between
|
||||
// realpath and read cannot swap the target, so the regular-file and size checks bind the bytes we
|
||||
// actually read (no path-based TOCTOU).
|
||||
const handle = await open(canonicalPath, 'r')
|
||||
try {
|
||||
const info = await handle.stat()
|
||||
if (!info.isFile()) {
|
||||
throw new Error(`source "${filePath}" is not a regular file`)
|
||||
}
|
||||
if (info.size > maxDocumentBytes) {
|
||||
throw new Error(`source "${filePath}" is ${info.size} bytes, over the ${maxDocumentBytes}-byte limit`)
|
||||
}
|
||||
const buffer = await handle.readFile()
|
||||
const text = decodeUtf8Strict(buffer, filePath)
|
||||
return { canonicalPath, text }
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
if (info.size > maxDocumentBytes) {
|
||||
throw new Error(`source "${filePath}" is ${info.size} bytes, over the ${maxDocumentBytes}-byte limit`)
|
||||
}
|
||||
const buffer = await readFile(canonicalPath)
|
||||
const text = decodeUtf8Strict(buffer, filePath)
|
||||
return { canonicalPath, text }
|
||||
}
|
||||
|
||||
/** Whether `child` is the workspace itself or a descendant of it (both already canonical). */
|
||||
@@ -88,13 +96,13 @@ function isInside(workspace: string, child: string): boolean {
|
||||
return child.startsWith(base)
|
||||
}
|
||||
|
||||
/** Decode UTF-8 strictly (a replacement char means the source was not valid UTF-8 text). */
|
||||
/** Decode strictly as UTF-8: a fatal decoder rejects only malformed bytes, keeping a legitimate U+FFFD. */
|
||||
function decodeUtf8Strict(buffer: Buffer, filePath: string): string {
|
||||
const text = buffer.toString('utf8')
|
||||
if (text.includes('<EFBFBD>')) {
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(buffer)
|
||||
} catch {
|
||||
throw new Error(`source "${filePath}" is not valid UTF-8 text`)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
/** Extract a message from an unknown thrown value without leaking `any`. */
|
||||
|
||||
Reference in New Issue
Block a user