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:
Dudu-0223
2026-07-16 13:11:07 +08:00
parent 575feaddfa
commit 8e8f90e235
12 changed files with 1038 additions and 218 deletions

View File

@@ -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`. */