refactor(e2b): group remote providers
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
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,
|
||||
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(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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
import { once } from 'node:events'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { encodeE2BFrame } from '@deepseek-ai/dsh-e2b'
|
||||
import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
|
||||
import { E2BLspTransport } from '@deepseek-ai/dsh-lsp-e2b'
|
||||
|
||||
class FakeHandle implements SubprocessHandle {
|
||||
readonly pid = 321
|
||||
readonly stdin: PassThrough | undefined
|
||||
readonly stdout: PassThrough | undefined
|
||||
readonly stderr = undefined
|
||||
readonly collected: SubprocessHandle['collected']
|
||||
readonly result = Promise.withResolvers<SubprocessOutcome>()
|
||||
readonly done = this.result.promise
|
||||
terminated = 0
|
||||
waitResult = true
|
||||
|
||||
constructor(options: { stdin?: boolean; stdout?: boolean; diagnostic?: string } = {}) {
|
||||
this.stdin = options.stdin === false ? undefined : new PassThrough()
|
||||
this.stdout = options.stdout === false ? undefined : new PassThrough()
|
||||
this.collected = options.diagnostic === undefined
|
||||
? {}
|
||||
: { stderr: { readFrom: () => ({ text: options.diagnostic as string, nextOffset: 0, lossy: false }) } }
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.terminated += 1
|
||||
}
|
||||
|
||||
async waitForExit(): Promise<boolean> {
|
||||
return this.waitResult
|
||||
}
|
||||
|
||||
resolve(outcome: SubprocessOutcome = { exitCode: 0, signal: null }): void {
|
||||
this.stdout?.end()
|
||||
this.result.resolve(outcome)
|
||||
}
|
||||
|
||||
reject(error: unknown): void {
|
||||
this.stdout?.end()
|
||||
this.result.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
function proxyFrame(type: 'stdout' | 'stderr', data: string | Buffer): string {
|
||||
return encodeE2BFrame({ type, data: Buffer.from(data).toString('base64') })
|
||||
}
|
||||
|
||||
describe('E2BLspTransport', () => {
|
||||
it('frames stdin, decodes split byte output, and exposes handle lifecycle', async () => {
|
||||
const inner = new FakeHandle({ diagnostic: 'proxy diagnostic' })
|
||||
const transport = new E2BLspTransport(inner, 1_024, 64)
|
||||
let stdin = ''
|
||||
inner.stdin?.on('data', (chunk) => { stdin += String(chunk) })
|
||||
let stdout = Buffer.alloc(0)
|
||||
transport.stdout.on('data', (chunk) => { stdout = Buffer.concat([stdout, chunk]) })
|
||||
|
||||
transport.stdin.write(Buffer.from([0, 0xff]))
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
const encodedInput = stdin.trim()
|
||||
const input = JSON.parse(Buffer.from(encodedInput, 'base64').toString('utf8')) as Record<string, string>
|
||||
expect(input).toEqual({ type: 'stdin', data: 'AP8=' })
|
||||
|
||||
const frames = proxyFrame('stdout', Buffer.from([0, 0xff]))
|
||||
+ proxyFrame('stderr', 'server diagnostic')
|
||||
+ encodeE2BFrame({ type: 'exit', code: 0, signal: null })
|
||||
inner.stdout?.write(frames.slice(0, 7))
|
||||
inner.stdout?.write(frames.slice(7))
|
||||
inner.stdout?.write(proxyFrame('stderr', ''))
|
||||
inner.stdout?.write(encodeE2BFrame({ type: 'exit', code: null, signal: 'SIGTERM' }))
|
||||
inner.resolve()
|
||||
|
||||
await expect(transport.done).resolves.toEqual({ exitCode: 0, signal: null })
|
||||
expect(stdout).toEqual(Buffer.from([0, 0xff]))
|
||||
expect(transport.collected.stderr?.readFrom(0).text).toBe('server diagnosticproxy diagnostic')
|
||||
expect(transport.pid).toBe(321)
|
||||
inner.waitResult = false
|
||||
await expect(transport.waitForExit()).resolves.toBe(false)
|
||||
transport.terminate()
|
||||
expect(inner.terminated).toBe(1)
|
||||
})
|
||||
|
||||
it('ends the inner stdin and retains a bounded byte tail with independent offsets', async () => {
|
||||
const inner = new FakeHandle()
|
||||
const transport = new E2BLspTransport(inner, 1_024, 4)
|
||||
const finished = once(inner.stdin!, 'finish')
|
||||
transport.stdin.end()
|
||||
await finished
|
||||
|
||||
inner.stdout?.write(proxyFrame('stderr', 'ab'))
|
||||
inner.stdout?.write(proxyFrame('stderr', 'cdef'))
|
||||
const reader = transport.collected.stderr!
|
||||
expect(reader.readFrom(0)).toEqual({ text: 'cdef', nextOffset: 6, lossy: true })
|
||||
expect(reader.readFrom(2)).toEqual({ text: 'cdef', nextOffset: 6, lossy: false })
|
||||
expect(reader.readFrom(5)).toEqual({ text: 'f', nextOffset: 6, lossy: false })
|
||||
expect(reader.readFrom(99)).toEqual({ text: '', nextOffset: 6, lossy: false })
|
||||
expect(() => reader.readFrom(-1)).toThrow('non-negative safe integer')
|
||||
expect(() => reader.readFrom(1.5)).toThrow('non-negative safe integer')
|
||||
inner.resolve()
|
||||
await transport.done
|
||||
|
||||
const partialInner = new FakeHandle()
|
||||
const partial = new E2BLspTransport(partialInner, 1_024, 4)
|
||||
partialInner.stdout?.write(proxyFrame('stderr', 'abcdef'))
|
||||
expect(partial.collected.stderr?.readFrom(0)).toEqual({ text: 'cdef', nextOffset: 6, lossy: true })
|
||||
partialInner.resolve()
|
||||
await partial.done
|
||||
})
|
||||
|
||||
it.each([
|
||||
['non-object', encodeE2BFrame(null), 'malformed frame'],
|
||||
['wrong type', encodeE2BFrame({ type: 'other', data: '' }), 'malformed frame'],
|
||||
['invalid exit', encodeE2BFrame({ type: 'exit', code: 'zero', signal: null }), 'malformed frame'],
|
||||
['missing data', encodeE2BFrame({ type: 'stdout' }), 'malformed frame'],
|
||||
['invalid base64', encodeE2BFrame({ type: 'stdout', data: 'abc' }), 'invalid base64'],
|
||||
['invalid outer frame', 'not-base64\n', 'invalid base64'],
|
||||
['non-ASCII outer frame', 'é', 'non-ASCII'],
|
||||
])('fails %s proxy traffic and terminates the group', async (_name, frame, message) => {
|
||||
const inner = new FakeHandle()
|
||||
const transport = new E2BLspTransport(inner, 1_024, 32)
|
||||
inner.stdout?.write(frame)
|
||||
inner.stdout?.write(proxyFrame('stdout', 'ignored'))
|
||||
inner.stdout?.emit('error', new Error('second failure'))
|
||||
inner.resolve()
|
||||
await expect(transport.done).rejects.toThrow(message)
|
||||
expect(inner.terminated).toBe(1)
|
||||
})
|
||||
|
||||
it('fails truncated frames and preserves inner spawn rejection', async () => {
|
||||
const truncatedInner = new FakeHandle()
|
||||
const truncated = new E2BLspTransport(truncatedInner, 1_024, 32)
|
||||
truncatedInner.stdout?.write('YQ==')
|
||||
truncatedInner.resolve()
|
||||
await expect(truncated.done).rejects.toThrow('mid-frame')
|
||||
|
||||
const rejectedInner = new FakeHandle({ diagnostic: 'tail' })
|
||||
const rejected = new E2BLspTransport(rejectedInner, 1_024, 32)
|
||||
rejectedInner.reject(new Error('spawn failed'))
|
||||
await expect(rejected.done).rejects.toThrow('spawn failed')
|
||||
expect(rejected.collected.stderr?.readFrom(0).text).toBe('tail')
|
||||
})
|
||||
|
||||
it('forwards output and input stream errors without an unhandled inner error', async () => {
|
||||
const outputInner = new FakeHandle()
|
||||
const output = new E2BLspTransport(outputInner, 1_024, 32)
|
||||
outputInner.stdout?.emit('error', new Error('proxy stdout failed'))
|
||||
outputInner.resolve()
|
||||
await expect(output.done).rejects.toThrow('proxy stdout failed')
|
||||
|
||||
const inputInner = new FakeHandle()
|
||||
const input = new E2BLspTransport(inputInner, 1_024, 32)
|
||||
const outerError = once(input.stdin, 'error')
|
||||
inputInner.stdin?.emit('error', new Error('proxy stdin failed'))
|
||||
await expect(outerError).resolves.toMatchObject([{ message: 'proxy stdin failed' }])
|
||||
inputInner.resolve()
|
||||
await input.done
|
||||
})
|
||||
|
||||
it('normalizes a non-Error decoder throw', async () => {
|
||||
const inner = new FakeHandle()
|
||||
const transport = new E2BLspTransport(inner, 1_024, 32)
|
||||
const internal = transport as unknown as {
|
||||
decoder: { push(chunk: string): unknown[] }
|
||||
onProxyData(chunk: Buffer): void
|
||||
}
|
||||
internal.decoder = { push: () => { throw 'raw decoder failure' } }
|
||||
internal.onProxyData(Buffer.from('x'))
|
||||
inner.resolve()
|
||||
await expect(transport.done).rejects.toThrow('raw decoder failure')
|
||||
})
|
||||
|
||||
it('rejects a subprocess that drops either required pipe', () => {
|
||||
const missingStdin = new FakeHandle({ stdin: false })
|
||||
const missingStdout = new FakeHandle({ stdout: false })
|
||||
expect(() => new E2BLspTransport(missingStdin, 10, 10)).toThrow('dropped a piped stream')
|
||||
expect(() => new E2BLspTransport(missingStdout, 10, 10)).toThrow('dropped a piped stream')
|
||||
expect(missingStdin.terminated).toBe(1)
|
||||
expect(missingStdout.terminated).toBe(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user