403 lines
17 KiB
TypeScript
403 lines
17 KiB
TypeScript
import { PassThrough } from 'node:stream'
|
|
import { Context } from 'cordis'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
FileType,
|
|
type Sandbox,
|
|
} from '@deepseek-ai/dsh-e2b'
|
|
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
|
import E2BSubprocessService from '@deepseek-ai/dsh-subprocess-e2b'
|
|
|
|
const mockedLsp = vi.hoisted(() => {
|
|
interface Plan {
|
|
query?: (...args: unknown[]) => unknown
|
|
transportFailure?: unknown
|
|
dead?: boolean
|
|
deadAfterQuery?: boolean
|
|
disposeError?: unknown
|
|
}
|
|
|
|
class FakeLspInstance {
|
|
static readonly instances: FakeLspInstance[] = []
|
|
static readonly plans: Plan[] = []
|
|
readonly plan: Plan
|
|
readonly transport: unknown
|
|
readonly queries: unknown[][] = []
|
|
dead: boolean
|
|
disposals = 0
|
|
|
|
constructor(
|
|
readonly spec: Record<string, unknown>,
|
|
spawner: (spec: SubprocessSpawnSpec) => unknown,
|
|
) {
|
|
this.plan = FakeLspInstance.plans.shift() ?? {}
|
|
this.dead = this.plan.dead === true
|
|
this.transport = spawner({
|
|
argv: [String(spec.command), ...(spec.args as string[])],
|
|
cwd: String(spec.cwd),
|
|
env: spec.env as Record<string, string>,
|
|
graceMs: Number(spec.killGraceMs),
|
|
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: Number(spec.maxStderrBytes) } },
|
|
})
|
|
FakeLspInstance.instances.push(this)
|
|
}
|
|
|
|
async query(...args: unknown[]): Promise<unknown> {
|
|
this.queries.push(args)
|
|
const result = await Promise.resolve(this.plan.query?.(...args) ?? { kind: 'hover', hover: null })
|
|
if (this.plan.deadAfterQuery === true) this.dead = true
|
|
return result
|
|
}
|
|
|
|
isTransportFailure(error: unknown): boolean {
|
|
return error === this.plan.transportFailure
|
|
}
|
|
|
|
async dispose(): Promise<void> {
|
|
this.disposals += 1
|
|
this.dead = true
|
|
if (this.plan.disposeError !== undefined) throw this.plan.disposeError
|
|
}
|
|
}
|
|
|
|
return { FakeLspInstance }
|
|
})
|
|
|
|
vi.mock('@deepseek-ai/dsh-lsp-local', () => ({ LspInstance: mockedLsp.FakeLspInstance }))
|
|
|
|
import {
|
|
E2BLspProvider,
|
|
apply,
|
|
canonicalizeE2BWorkspace,
|
|
e2bFileUri,
|
|
readE2BSource,
|
|
} from '@deepseek-ai/dsh-lsp-e2b'
|
|
import type { LspE2BServerConfig } from '@deepseek-ai/dsh-lsp-e2b'
|
|
import * as E2BLspInvariant from '../src/invariant.ts'
|
|
import InvariantService from '@deepseek-ai/dsh-invariants'
|
|
|
|
class FakeInnerHandle implements SubprocessHandle {
|
|
readonly pid = 777
|
|
readonly stdin = new PassThrough()
|
|
readonly stdout = new PassThrough()
|
|
readonly stderr = undefined
|
|
readonly collected = { stderr: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) } }
|
|
readonly done = Promise.resolve({ exitCode: 0, signal: null })
|
|
terminate(): void {}
|
|
async waitForExit(): Promise<boolean> { return true }
|
|
}
|
|
|
|
class FakeRemote {
|
|
readonly writes: Array<Array<{ path: string; data: string }>> = []
|
|
readonly commands: string[] = []
|
|
readonly infos = new Map<string, { type: FileType; size: number }>()
|
|
readonly contents = new Map<string, Uint8Array>()
|
|
readonly realpaths = new Map<string, string>()
|
|
forcedRealpath: string | undefined
|
|
|
|
constructor() {
|
|
this.infos.set('/workspace', { type: FileType.DIR, size: 0 })
|
|
this.infos.set('/workspace/file.ts', { type: FileType.FILE, size: 12 })
|
|
this.contents.set('/workspace/file.ts', Buffer.from('const x = 1'))
|
|
}
|
|
|
|
readonly sandbox = {
|
|
commands: {
|
|
run: async (command: string) => {
|
|
this.commands.push(command)
|
|
if (command.startsWith('realpath ')) {
|
|
const match = /'([^']*)'$/.exec(command)
|
|
const requested = match?.[1] ?? ''
|
|
return { exitCode: 0, stdout: `${this.forcedRealpath ?? this.realpaths.get(requested) ?? requested}\n`, stderr: '' }
|
|
}
|
|
if (command.startsWith('command -v')) return { exitCode: 0, stdout: '/usr/bin/node\n', stderr: '' }
|
|
return { exitCode: 0, stdout: '', stderr: '' }
|
|
},
|
|
},
|
|
files: {
|
|
write: async (files: Array<{ path: string; data: string }>) => {
|
|
this.writes.push(files)
|
|
return files.map(() => ({}))
|
|
},
|
|
getInfo: async (path: string) => {
|
|
const info = this.infos.get(path)
|
|
if (info === undefined) throw new Error(`missing info for ${path}`)
|
|
return info
|
|
},
|
|
read: async (path: string) => this.contents.get(path) ?? new Uint8Array(),
|
|
},
|
|
} as unknown as Sandbox
|
|
}
|
|
|
|
function subprocess(spawn = vi.fn((_spec: SubprocessSpawnSpec) => new FakeInnerHandle())): E2BSubprocessService {
|
|
const service = Object.create(E2BSubprocessService.prototype) as E2BSubprocessService
|
|
Object.defineProperty(service, 'spawn', { value: spawn })
|
|
return service
|
|
}
|
|
|
|
function server(overrides: Partial<LspE2BServerConfig> = {}): Required<LspE2BServerConfig> {
|
|
return {
|
|
command: '/usr/bin/server', args: ['--stdio'], env: {},
|
|
extensionToLanguage: { '.ts': 'typescript' },
|
|
initializationOptions: null, configuration: null,
|
|
maxMessageBytes: 1_024, maxStderrBytes: 128, maxDocumentBytes: 1_024,
|
|
shutdownTimeoutMs: 100, killGraceMs: 50,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function provider(remote = new FakeRemote(), service = subprocess()): E2BLspProvider {
|
|
return new E2BLspProvider(
|
|
'fixture', remote.sandbox, service, server(),
|
|
'/usr/bin/server', '/usr/bin/node', '/workspace/.dsh-e2b/lsp-proxy.mjs',
|
|
)
|
|
}
|
|
|
|
function query(workspaceRoot = '/workspace') {
|
|
return {
|
|
operation: 'hover' as const,
|
|
filePath: 'file.ts',
|
|
position: { line: 0, character: 1 },
|
|
workspaceRoot,
|
|
languageId: 'typescript',
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockedLsp.FakeLspInstance.instances.length = 0
|
|
mockedLsp.FakeLspInstance.plans.length = 0
|
|
})
|
|
|
|
describe('E2B LSP filesystem boundary', () => {
|
|
it('canonicalizes a directory and reads a contained UTF-8 source', async () => {
|
|
const remote = new FakeRemote()
|
|
await expect(canonicalizeE2BWorkspace(remote.sandbox, '/workspace')).resolves.toBe('/workspace')
|
|
await expect(readE2BSource(remote.sandbox, 'file.ts', '/workspace', 1_024)).resolves.toEqual({
|
|
canonicalPath: '/workspace/file.ts',
|
|
text: 'const x = 1',
|
|
})
|
|
await expect(readE2BSource(remote.sandbox, '/workspace/file.ts', '/workspace', 1_024)).resolves.toMatchObject({
|
|
canonicalPath: '/workspace/file.ts',
|
|
})
|
|
const signal = new AbortController().signal
|
|
await expect(canonicalizeE2BWorkspace(remote.sandbox, '/workspace', signal)).resolves.toBe('/workspace')
|
|
await expect(readE2BSource(remote.sandbox, 'file.ts', '/workspace', 1_024, signal)).resolves.toMatchObject({
|
|
canonicalPath: '/workspace/file.ts',
|
|
})
|
|
})
|
|
|
|
it('rejects malformed workspaces and source containment/type/size/encoding failures', async () => {
|
|
const malformed = new FakeRemote()
|
|
malformed.forcedRealpath = 'relative'
|
|
await expect(canonicalizeE2BWorkspace(malformed.sandbox, '/workspace')).rejects.toThrow('did not resolve canonically')
|
|
malformed.forcedRealpath = '/workspace\nother'
|
|
await expect(canonicalizeE2BWorkspace(malformed.sandbox, '/workspace')).rejects.toThrow('did not resolve canonically')
|
|
|
|
const notDirectory = new FakeRemote()
|
|
notDirectory.infos.set('/workspace', { type: FileType.FILE, size: 0 })
|
|
await expect(canonicalizeE2BWorkspace(notDirectory.sandbox, '/workspace')).rejects.toThrow('not a directory')
|
|
|
|
const outside = new FakeRemote()
|
|
outside.realpaths.set('/workspace/file.ts', '/outside/file.ts')
|
|
await expect(readE2BSource(outside.sandbox, 'file.ts', '/workspace', 20)).rejects.toThrow('outside the workspace')
|
|
|
|
const notFile = new FakeRemote()
|
|
notFile.infos.set('/workspace/file.ts', { type: FileType.DIR, size: 0 })
|
|
await expect(readE2BSource(notFile.sandbox, 'file.ts', '/workspace', 20)).rejects.toThrow('not a regular file')
|
|
|
|
const tooLarge = new FakeRemote()
|
|
tooLarge.infos.set('/workspace/file.ts', { type: FileType.FILE, size: 21 })
|
|
await expect(readE2BSource(tooLarge.sandbox, 'file.ts', '/workspace', 20)).rejects.toThrow('over the 20-byte limit')
|
|
|
|
const grew = new FakeRemote()
|
|
grew.infos.set('/workspace/file.ts', { type: FileType.FILE, size: 1 })
|
|
grew.contents.set('/workspace/file.ts', Buffer.alloc(21))
|
|
await expect(readE2BSource(grew.sandbox, 'file.ts', '/workspace', 20)).rejects.toThrow('grew past')
|
|
|
|
const invalid = new FakeRemote()
|
|
invalid.infos.set('/workspace/file.ts', { type: FileType.FILE, size: 1 })
|
|
invalid.contents.set('/workspace/file.ts', Uint8Array.from([0xff]))
|
|
await expect(readE2BSource(invalid.sandbox, 'file.ts', '/workspace', 20)).rejects.toThrow('not valid UTF-8')
|
|
|
|
await expect(canonicalizeE2BWorkspace(new FakeRemote().sandbox, '/workspace', AbortSignal.abort('stop')))
|
|
.rejects.toBe('stop')
|
|
})
|
|
})
|
|
|
|
describe('E2BLspProvider pooling and lifecycle', () => {
|
|
it('reuses one canonical-workspace instance and constructs the remote proxy transport', async () => {
|
|
const spawn = vi.fn((_spec: SubprocessSpawnSpec) => new FakeInnerHandle())
|
|
const remote = new FakeRemote()
|
|
mockedLsp.FakeLspInstance.plans.push({ query: async () => ({ kind: 'hover', hover: { contents: 'ok' } }) })
|
|
const current = provider(remote, subprocess(spawn))
|
|
|
|
await expect(current.query(query())).resolves.toEqual({ kind: 'hover', hover: { contents: 'ok' } })
|
|
await expect(current.query(query())).resolves.toEqual({ kind: 'hover', hover: { contents: 'ok' } })
|
|
expect(mockedLsp.FakeLspInstance.instances).toHaveLength(1)
|
|
expect(mockedLsp.FakeLspInstance.instances[0]?.spec).toMatchObject({ clientProcessId: null, cwd: '/workspace' })
|
|
expect(e2bFileUri('/workspace/a b#c.ts')).toBe('file:///workspace/a%20b%23c.ts')
|
|
expect(() => e2bFileUri('relative.ts')).toThrow('absolute remote path')
|
|
const pathToFileUri = mockedLsp.FakeLspInstance.instances[0]?.spec.pathToFileUri as (path: string) => string
|
|
expect(pathToFileUri('/workspace/a b#c.ts')).toBe('file:///workspace/a%20b%23c.ts')
|
|
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
|
|
argv: ['/usr/bin/node', '/workspace/.dsh-e2b/lsp-proxy.mjs', expect.any(String)],
|
|
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 128 } },
|
|
}))
|
|
expect(current.id).toBe('fixture')
|
|
expect(current.extensionToLanguage).toEqual({ '.ts': 'typescript' })
|
|
await current.disposeAll()
|
|
expect(mockedLsp.FakeLspInstance.instances[0]?.disposals).toBe(1)
|
|
})
|
|
|
|
it('replaces one transport failure, but preserves ordinary query errors', async () => {
|
|
const transportFailure = new Error('transport failed')
|
|
mockedLsp.FakeLspInstance.plans.push(
|
|
{ transportFailure, query: async () => { throw transportFailure } },
|
|
{ query: async () => ({ kind: 'hover', hover: { contents: 'retried' } }) },
|
|
)
|
|
const retried = provider()
|
|
await expect(retried.query(query())).resolves.toMatchObject({ hover: { contents: 'retried' } })
|
|
expect(mockedLsp.FakeLspInstance.instances[0]?.disposals).toBe(1)
|
|
expect(mockedLsp.FakeLspInstance.instances).toHaveLength(2)
|
|
|
|
const ordinary = new Error('ordinary failure')
|
|
mockedLsp.FakeLspInstance.plans.push(
|
|
{ query: async () => { throw ordinary }, dead: true },
|
|
{ query: async () => ({ kind: 'hover', hover: null }) },
|
|
)
|
|
const failed = provider()
|
|
await expect(failed.query(query())).rejects.toBe(ordinary)
|
|
await expect(failed.query(query())).resolves.toMatchObject({ kind: 'hover' })
|
|
})
|
|
|
|
it('evicts a server that dies after a successful query', async () => {
|
|
mockedLsp.FakeLspInstance.plans.push(
|
|
{ deadAfterQuery: true, query: async () => ({ kind: 'hover', hover: null }) },
|
|
{ query: async () => ({ kind: 'hover', hover: null }) },
|
|
)
|
|
const current = provider()
|
|
await current.query(query())
|
|
await current.query(query())
|
|
expect(mockedLsp.FakeLspInstance.instances).toHaveLength(2)
|
|
expect(mockedLsp.FakeLspInstance.instances[0]?.disposals).toBe(1)
|
|
})
|
|
|
|
it('serializes a workspace queue, observes queued abort, and awaits work on disposal', async () => {
|
|
const first = Promise.withResolvers<unknown>()
|
|
mockedLsp.FakeLspInstance.plans.push({ query: () => first.promise })
|
|
const current = provider()
|
|
const running = current.query(query())
|
|
const controller = new AbortController()
|
|
const queued = current.query(query(), controller.signal)
|
|
await new Promise(resolve => setImmediate(resolve))
|
|
controller.abort('queued stop')
|
|
await expect(queued).rejects.toBe('queued stop')
|
|
|
|
const disposing = current.disposeAll()
|
|
first.resolve({ kind: 'hover', hover: null })
|
|
await expect(running).resolves.toMatchObject({ kind: 'hover' })
|
|
await disposing
|
|
await expect(current.query(query())).rejects.toMatchObject({ code: 'LSP_DISPOSED' })
|
|
})
|
|
|
|
it('covers pre-abort, synthetic abort, resolve, and rejection in the queue race', async () => {
|
|
const current = provider()
|
|
const internal = current as unknown as {
|
|
queues: Map<string, Promise<void>>
|
|
enqueue<T>(workspace: string, signal: AbortSignal | undefined, run: () => Promise<T>): Promise<T>
|
|
}
|
|
await expect(internal.enqueue('pre', AbortSignal.abort('pre-stop'), async () => 'unused')).rejects.toBe('pre-stop')
|
|
|
|
const signal = new AbortController().signal
|
|
await expect(internal.enqueue('resolve', signal, async () => 'ok')).resolves.toBe('ok')
|
|
|
|
const failure = new Error('queue failed')
|
|
const rejected = Promise.reject<undefined>(failure)
|
|
void rejected.catch(() => {})
|
|
internal.queues.set('reject', rejected)
|
|
await expect(internal.enqueue('reject', signal, async () => 'unused')).rejects.toBe(failure)
|
|
|
|
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- Exercise normalization at the promise boundary.
|
|
const opaque = Promise.reject<undefined>('opaque queue failure')
|
|
void opaque.catch(() => {})
|
|
internal.queues.set('opaque', opaque)
|
|
await expect(internal.enqueue('opaque', signal, async () => 'unused')).rejects.toEqual(new Error('opaque queue failure'))
|
|
|
|
const synthetic = {
|
|
aborted: false,
|
|
reason: undefined,
|
|
throwIfAborted() {},
|
|
addEventListener(_type: string, listener: () => void) { listener() },
|
|
removeEventListener() {},
|
|
} as unknown as AbortSignal
|
|
await expect(internal.enqueue('synthetic', synthetic, async () => 'unused')).rejects.toMatchObject({ name: 'AbortError' })
|
|
await current.disposeAll()
|
|
})
|
|
})
|
|
|
|
describe('lsp-e2b plugin composition', () => {
|
|
function pluginContext(
|
|
remote: FakeRemote,
|
|
service: E2BSubprocessService,
|
|
registerProvider = vi.fn(() => vi.fn()),
|
|
) {
|
|
const effects: Array<() => void | Promise<void>> = []
|
|
const ctx = {
|
|
subprocess: service,
|
|
e2b: {
|
|
runtimeRoot: '/workspace/.dsh-e2b',
|
|
getSandbox: async () => remote.sandbox,
|
|
},
|
|
lsp: { registerProvider },
|
|
effect: (callback: () => (() => void | Promise<void>)) => { effects.push(callback()) },
|
|
} as unknown as Context
|
|
return { ctx, effects, registerProvider }
|
|
}
|
|
|
|
it('installs one proxy, resolves commands, registers providers, and disposes them', async () => {
|
|
const remote = new FakeRemote()
|
|
const fixture = pluginContext(remote, subprocess())
|
|
await apply(fixture.ctx, { servers: { one: server(), two: server({ command: 'server-two' }) } })
|
|
expect(remote.writes).toHaveLength(1)
|
|
expect(remote.writes[0]?.[0]?.path).toBe('/workspace/.dsh-e2b/lsp-stdio-proxy.mjs')
|
|
expect(remote.commands).toContain("chmod 600 -- '/workspace/.dsh-e2b/lsp-stdio-proxy.mjs'")
|
|
expect(fixture.registerProvider).toHaveBeenCalledTimes(2)
|
|
await fixture.effects[0]?.()
|
|
})
|
|
|
|
it('rolls back partial registration and rejects invalid composition/configuration', async () => {
|
|
const remote = new FakeRemote()
|
|
const firstDispose = vi.fn()
|
|
const register = vi.fn()
|
|
.mockReturnValueOnce(firstDispose)
|
|
.mockImplementationOnce(() => { throw new Error('duplicate provider') })
|
|
const rollback = pluginContext(remote, subprocess(), register)
|
|
await expect(apply(rollback.ctx, { servers: { one: server(), two: server() } })).rejects.toThrow('duplicate provider')
|
|
expect(firstDispose).toHaveBeenCalledOnce()
|
|
|
|
const wrong = pluginContext(remote, {} as E2BSubprocessService)
|
|
await expect(apply(wrong.ctx, { servers: { one: server() } })).rejects.toThrow('dsh-subprocess-e2b')
|
|
|
|
const empty = pluginContext(remote, subprocess())
|
|
await expect(apply(empty.ctx, { servers: {} })).rejects.toThrow('at least one server')
|
|
|
|
for (const [id, config] of [
|
|
['', server()],
|
|
['one', server({ command: '' })],
|
|
['one', server({ maxMessageBytes: 0 })],
|
|
['one', server({ maxStderrBytes: 1.5 })],
|
|
['one', server({ shutdownTimeoutMs: 2_147_483_648 })],
|
|
] as const) {
|
|
const fixture = pluginContext(new FakeRemote(), subprocess())
|
|
await expect(apply(fixture.ctx, { servers: { [id]: config } })).rejects.toThrow()
|
|
}
|
|
})
|
|
|
|
it('registers the package-owned invariant companion', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(InvariantService, { enabled: true })
|
|
const fiber = await ctx.plugin(E2BLspInvariant).await()
|
|
await fiber.dispose()
|
|
})
|
|
})
|