refactor(runtime): compose consumers over fs and subprocess

This commit is contained in:
Tianyi Cui
2026-07-28 23:00:00 +08:00
parent 987d477cfc
commit 8917ff8ef4
116 changed files with 2828 additions and 1122 deletions

View File

@@ -16,8 +16,9 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
const pkgDir = fileURLToPath(new URL('..', import.meta.url))
const seamLib = join(pkgDir, '../lsp/lib/index.js')
const fsLib = join(pkgDir, '../../fs/fs-local/lib/index.js')
const subprocessLib = join(pkgDir, '../../subprocess/subprocess-local/lib/index.js')
const built = existsSync(join(pkgDir, 'lib/index.js')) && existsSync(seamLib) && existsSync(subprocessLib)
const built = existsSync(join(pkgDir, 'lib/index.js')) && existsSync(seamLib) && existsSync(fsLib) && existsSync(subprocessLib)
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
@@ -42,10 +43,12 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
const { Context } = await import('cordis')
const { default: Lsp } = await import('@deepseek-ai/dsh-lsp')
const LspLocal = await import('@deepseek-ai/dsh-lsp-local')
const { default: LocalFileSystem } = await import('@deepseek-ai/dsh-fs-local')
const { default: LocalSubprocessService } = await import('@deepseek-ai/dsh-subprocess-local')
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await ctx.plugin(LspLocal, {
servers: {
fake: {

View File

@@ -4,7 +4,10 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { realpath } from 'node:fs/promises'
import { execFile } from 'node:child_process'
import { pathToFileURL } from 'node:url'
import { promisify } from 'node:util'
import { Context } from 'cordis'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import { deadline } from '@deepseek-ai/dsh-timeout'
import { canonicalizeWorkspace, readHostSource } from '@deepseek-ai/dsh-lsp-local'
@@ -12,84 +15,105 @@ const execFileAsync = promisify(execFile)
let root: string
let ws: string
let ctx: Context
let fs: LocalFileSystem
beforeEach(async () => {
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-host-')))
ws = join(root, 'ws')
await mkdir(ws)
ctx = new Context()
await ctx.plugin(LocalFileSystem, { cwd: root })
fs = ctx.fs as LocalFileSystem
})
afterEach(async () => {
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
})
const BIG = 1_000_000
async function workspace() {
return await canonicalizeWorkspace(fs, ws)
}
async function readSource(filePath: string, maxBytes = BIG, signal?: AbortSignal) {
return await readHostSource(fs, filePath, await workspace(), maxBytes, signal)
}
describe('canonicalizeWorkspace', () => {
it('returns the realpath of a directory', async () => {
expect(await canonicalizeWorkspace(ws)).toBe(ws)
expect((await workspace()).canonicalPath).toBe(ws)
})
it('resolves a symlinked workspace to its target so aliases share identity', async () => {
const link = join(root, 'ws-link')
await symlink(ws, link)
expect(await canonicalizeWorkspace(link)).toBe(ws)
expect((await canonicalizeWorkspace(fs, link)).canonicalPath).toBe(ws)
})
it('rejects a missing workspace', async () => {
await expect(canonicalizeWorkspace(join(root, 'nope'))).rejects.toThrow(/cannot be resolved/)
await expect(canonicalizeWorkspace(fs, join(root, 'nope'))).rejects.toThrow(/not a directory/)
})
it('rejects a non-directory workspace', async () => {
const file = join(root, 'file.txt')
await writeFile(file, 'x')
await expect(canonicalizeWorkspace(file)).rejects.toThrow(/not a directory/)
await expect(canonicalizeWorkspace(fs, file)).rejects.toThrow(/not a directory/)
})
})
describe('readHostSource', () => {
it('reads a relative path against the workspace', async () => {
await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
const source = await readHostSource('a.ts', ws, BIG)
expect(source.canonicalPath).toBe(join(ws, 'a.ts'))
const source = await readSource('a.ts')
expect(source.fileUrl).toBe(pathToFileURL(join(ws, 'a.ts')).href)
expect(source.text).toBe('const x = 1\n')
})
it('reads an absolute path inside the workspace', async () => {
const abs = join(ws, 'b.ts')
await writeFile(abs, 'b')
const source = await readHostSource(abs, ws, BIG)
expect(source.canonicalPath).toBe(abs)
const source = await readSource(abs)
expect(source.fileUrl).toBe(pathToFileURL(abs).href)
})
it('accepts a source reached through a symlink that stays inside the workspace', async () => {
await mkdir(join(ws, 'real'))
await writeFile(join(ws, 'real', 'c.ts'), 'c')
await symlink(join(ws, 'real'), join(ws, 'linked'))
const source = await readHostSource('linked/c.ts', ws, BIG)
expect(source.canonicalPath).toBe(join(ws, 'real', 'c.ts'))
const source = await readSource('linked/c.ts')
expect(source.fileUrl).toBe(pathToFileURL(join(ws, 'real', 'c.ts')).href)
})
it('rejects a source whose canonical path escapes the workspace via symlink', async () => {
const outside = join(root, 'outside.ts')
await writeFile(outside, 'secret')
await symlink(outside, join(ws, 'escape.ts'))
await expect(readHostSource('escape.ts', ws, BIG)).rejects.toThrow(/outside the workspace/)
await expect(readSource('escape.ts')).rejects.toThrow(/outside the workspace/)
})
it('rejects an absolute source outside the workspace', async () => {
const outside = join(root, 'out.ts')
await writeFile(outside, 'x')
await expect(readHostSource(outside, ws, BIG)).rejects.toThrow(/outside the workspace/)
await expect(readSource(outside)).rejects.toThrow(/outside the workspace/)
})
it('rejects a missing source', async () => {
await expect(readHostSource('nope.ts', ws, BIG)).rejects.toThrow(/cannot be resolved/)
await expect(readSource('nope.ts')).rejects.toThrow(/not found/)
})
it('wraps a provider failure while resolving the source', async () => {
const canonical = await workspace()
fs.resolve = async () => { throw 'raw resolve failure' }
await expect(readHostSource(fs, 'broken.ts', canonical, BIG))
.rejects.toThrow('source "broken.ts" cannot be resolved: raw resolve failure')
})
it('rejects a non-regular source (directory)', async () => {
await mkdir(join(ws, 'dir'))
await expect(readHostSource('dir', ws, BIG)).rejects.toThrow(/not a regular file/)
await expect(readSource('dir')).rejects.toThrow(/not a regular file/)
})
// Windows has no filesystem FIFO; the directory case above pins non-regular rejection there.
@@ -97,36 +121,36 @@ describe('readHostSource', () => {
const fifo = join(ws, 'pipe.ts')
await execFileAsync('mkfifo', [fifo])
using d = deadline(undefined, 1000, 'FIFO_READ_TIMEOUT')
await expect(readHostSource('pipe.ts', ws, BIG, d.signal)).rejects.toThrow(/not a regular file/)
await expect(readSource('pipe.ts', BIG, d.signal)).rejects.toThrow(/not a regular file/)
})
it('honors a pre-aborted source read before filesystem work', async () => {
const controller = new AbortController()
controller.abort(new Error('source read cancelled'))
await expect(readHostSource('missing.ts', ws, BIG, controller.signal)).rejects.toThrow(/source read cancelled/)
await expect(readSource('missing.ts', BIG, controller.signal)).rejects.toThrow(/source read cancelled/)
})
it('treats the workspace root itself as inside, then rejects it as non-regular', async () => {
// filePath '.' canonicalizes to the workspace dir: isInside's identity branch is taken, and the
// directory then fails the regular-file check.
await expect(readHostSource('.', ws, BIG)).rejects.toThrow(/not a regular file/)
// The filesystem containment primitive accepts the workspace itself; the
// bounded read then rejects the directory as non-regular.
await expect(readSource('.')).rejects.toThrow(/not a regular file/)
})
it('rejects an oversized source', async () => {
await writeFile(join(ws, 'big.ts'), 'x'.repeat(100))
await expect(readHostSource('big.ts', ws, 10)).rejects.toThrow(/over the 10-byte limit/)
await expect(readSource('big.ts', 10)).rejects.toThrow(/10-byte limit/)
})
it('rejects a non-UTF-8 source', async () => {
await writeFile(join(ws, 'bin.ts'), Buffer.from([0xff, 0xfe, 0x00]))
await expect(readHostSource('bin.ts', ws, BIG)).rejects.toThrow(/not valid UTF-8/)
await expect(readSource('bin.ts')).rejects.toThrow(/invalid UTF-8|binary file/)
})
it('keeps a valid U+FFFD replacement character in otherwise-valid UTF-8', async () => {
// The literal replacement char is valid UTF-8; a fatal decoder must accept it (only malformed
// byte sequences are rejected).
await writeFile(join(ws, 'repl.ts'), 'const s = "<22>"\n')
const source = await readHostSource('repl.ts', ws, BIG)
const source = await readSource('repl.ts')
expect(source.text).toBe('const s = "<22>"\n')
})
})

View File

@@ -3,6 +3,8 @@ import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promi
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL, fileURLToPath } from 'node:url'
import { Context } from 'cordis'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-local'
import { encodeMessage } from '@deepseek-ai/dsh-lsp-local'
import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
@@ -15,6 +17,8 @@ const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.u
let root: string
let ws: string
let ctx: Context
let fs: LocalFileSystem
let live: LspInstance[] = []
beforeEach(async () => {
@@ -22,11 +26,15 @@ beforeEach(async () => {
ws = join(root, 'ws')
await mkdir(ws)
await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
ctx = new Context()
await ctx.plugin(LocalFileSystem, { cwd: root })
fs = ctx.fs as LocalFileSystem
})
afterEach(async () => {
for (const instance of live) await instance.dispose()
live = []
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
})
@@ -39,6 +47,7 @@ function makeInstance(
command: process.execPath,
args: [fixtureServer],
cwd: ws,
workspaceUri: pathToFileURL(ws).href,
env: { ...scrubbedParentEnv(), ...env },
configuration: { setting: 42 },
initializationOptions: { init: true },
@@ -58,7 +67,12 @@ function query(operation: LspProviderQuery['operation'] = 'goToDefinition'): Lsp
/** Run a query against an instance, reading the source first the way the provider does. */
async function run(instance: LspInstance, operation: LspProviderQuery['operation'] = 'goToDefinition', signal?: AbortSignal): Promise<LspQueryResult> {
const source = await readHostSource('a.ts', ws, 4_000_000)
const workspace = {
target: await fs.resolve(ws),
canonicalPath: ws,
fileUrl: pathToFileURL(ws).href,
}
const source = await readHostSource(fs, 'a.ts', workspace, 4_000_000)
return instance.query(query(operation), source, signal)
}
@@ -68,6 +82,7 @@ function scriptInstance(script: string, overrides: Partial<InstanceSpec> = {}):
command: process.execPath,
args: ['-e', script],
cwd: ws,
workspaceUri: pathToFileURL(ws).href,
env: scrubbedParentEnv(),
configuration: null,
initializationOptions: null,

View File

@@ -8,6 +8,7 @@ import { Context } from 'cordis'
import Lsp, { type LspProvider, type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
import { deadline } from '@deepseek-ai/dsh-timeout'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
import type { LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local'
@@ -47,6 +48,7 @@ async function mount(
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
const register = ctx.lsp.registerProvider.bind(ctx.lsp)
const registrationSpy = captureProvider === undefined
? undefined
@@ -79,6 +81,7 @@ describe('lsp-local end to end over a fake server', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await ctx.plugin(LspLocal, {
servers: {
typescript: fakeServer({ LSP_FAKE_HOVER: JSON.stringify({ contents: 'ts' }) }),
@@ -322,6 +325,7 @@ describe('lsp-local end to end over a fake server', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, {
servers: {
missing: {

View File

@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
import { delimiter, join } from 'node:path'
import { Context } from 'cordis'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp'
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
import type { Config, LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local'
@@ -44,6 +45,7 @@ describe('lsp-local provider resolution', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, config('onpath', {
command: 'fake-lsp',
args: [],
@@ -57,6 +59,7 @@ describe('lsp-local provider resolution', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, config('nope', {
command: 'fake-lsp',
args: [],
@@ -71,6 +74,7 @@ describe('lsp-local provider resolution', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
// Grab the provider instance by registering, then dispose the whole plugin fiber.
const lsp = ctx.lsp
const fiber = await ctx.plugin(LspLocal, config('disp', {
@@ -88,6 +92,7 @@ describe('lsp-local provider resolution', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, config('bad-budget', {
command: process.execPath,
args: ['-e', ''],
@@ -101,6 +106,7 @@ describe('lsp-local provider resolution', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, config('bad-cap', {
command: process.execPath,
args: ['-e', ''],
@@ -114,6 +120,7 @@ describe('lsp-local provider resolution', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, config('bad-timer', {
command: process.execPath,
args: ['-e', ''],
@@ -130,6 +137,7 @@ describe('lsp-local provider resolution', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, config('abs-bad', {
command: notExe,
args: [],
@@ -142,6 +150,7 @@ describe('lsp-local provider resolution', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, config('abs-directory', {
command: ws,
args: [],
@@ -154,6 +163,7 @@ describe('lsp-local provider resolution', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, { servers: {} })).rejects.toThrow(/servers must contain at least one server/)
await ctx.fiber.dispose()
})
@@ -162,6 +172,7 @@ describe('lsp-local provider resolution', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, config('', {
command: process.execPath,
extensionToLanguage: { '.ts': 'typescript' },
@@ -173,6 +184,7 @@ describe('lsp-local provider resolution', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, {
servers: {
valid: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } },
@@ -187,6 +199,7 @@ describe('lsp-local provider resolution', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, {
servers: {
first: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } },

View File

@@ -11,6 +11,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
@@ -54,6 +55,7 @@ beforeAll(async () => {
ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await ctx.plugin(LspLocal, {
servers: {
typescript: {