fix(runtime): close measured portability defects
This commit is contained in:
@@ -100,15 +100,14 @@ export async function readHostSource(
|
|||||||
for await (const chunk of stream) {
|
for await (const chunk of stream) {
|
||||||
throwIfAborted(signal)
|
throwIfAborted(signal)
|
||||||
bytes += Buffer.byteLength(chunk)
|
bytes += Buffer.byteLength(chunk)
|
||||||
if (bytes > maxDocumentBytes) {
|
if (bytes > maxDocumentBytes) break
|
||||||
throw new Error(`source "${filePath}" exceeds the ${maxDocumentBytes}-byte limit`)
|
|
||||||
}
|
|
||||||
chunks.push(chunk)
|
chunks.push(chunk)
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
throwIfAborted(signal)
|
throwIfAborted(signal)
|
||||||
throw new Error(`source "${filePath}" could not be read: ${messageOf(error)}`, { cause: error })
|
throw new Error(`source "${filePath}" could not be read: ${messageOf(error)}`, { cause: error })
|
||||||
}
|
}
|
||||||
|
if (bytes > maxDocumentBytes) throw new Error(`source "${filePath}" exceeds the ${maxDocumentBytes}-byte limit`)
|
||||||
throwIfAborted(signal)
|
throwIfAborted(signal)
|
||||||
return {
|
return {
|
||||||
fileUrl: fs.fileUrl(target),
|
fileUrl: fs.fileUrl(target),
|
||||||
|
|||||||
@@ -129,27 +129,29 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
|||||||
// Resolve every server-local setting before registration so a bad later command or bound cannot
|
// Resolve every server-local setting before registration so a bad later command or bound cannot
|
||||||
// publish an earlier provider. Registry-level mapping conflicts are rolled back below.
|
// publish an earlier provider. Registry-level mapping conflicts are rolled back below.
|
||||||
const providers = await (async () => {
|
const providers = await (async () => {
|
||||||
|
const lookups = entries.map(async ([providerId, rawConfig]) => {
|
||||||
|
if (providerId.trim() === '') throw new Error('lsp-local: server ids must be non-empty strings')
|
||||||
|
const resolved = rawConfig as ResolvedServerConfig
|
||||||
|
validateServerConfig(providerId, resolved)
|
||||||
|
const executable = await ctx.subprocess.resolveExecutable(
|
||||||
|
resolved.command,
|
||||||
|
resolved.env,
|
||||||
|
setupAbort.signal,
|
||||||
|
)
|
||||||
|
setupAbort.signal.throwIfAborted()
|
||||||
|
return new LocalLspProvider(
|
||||||
|
providerId,
|
||||||
|
ctx.fs,
|
||||||
|
resolved,
|
||||||
|
executable,
|
||||||
|
spec => ctx.subprocess.spawn(spec),
|
||||||
|
)
|
||||||
|
})
|
||||||
try {
|
try {
|
||||||
return await Promise.all(entries.map(async ([providerId, rawConfig]) => {
|
return await Promise.all(lookups)
|
||||||
if (providerId.trim() === '') throw new Error('lsp-local: server ids must be non-empty strings')
|
|
||||||
const resolved = rawConfig as ResolvedServerConfig
|
|
||||||
validateServerConfig(providerId, resolved)
|
|
||||||
const executable = await ctx.subprocess.resolveExecutable(
|
|
||||||
resolved.command,
|
|
||||||
resolved.env,
|
|
||||||
setupAbort.signal,
|
|
||||||
)
|
|
||||||
setupAbort.signal.throwIfAborted()
|
|
||||||
return new LocalLspProvider(
|
|
||||||
providerId,
|
|
||||||
ctx.fs,
|
|
||||||
resolved,
|
|
||||||
executable,
|
|
||||||
spec => ctx.subprocess.spawn(spec),
|
|
||||||
)
|
|
||||||
}))
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setupAbort.abort(error)
|
setupAbort.abort(error)
|
||||||
|
await Promise.allSettled(lookups)
|
||||||
throw error
|
throw error
|
||||||
} finally {
|
} finally {
|
||||||
stopSetupCancellation()
|
stopSetupCancellation()
|
||||||
|
|||||||
@@ -158,7 +158,9 @@ describe('readHostSource', () => {
|
|||||||
|
|
||||||
it('rejects an oversized source', async () => {
|
it('rejects an oversized source', async () => {
|
||||||
await writeFile(join(ws, 'big.ts'), 'x'.repeat(100))
|
await writeFile(join(ws, 'big.ts'), 'x'.repeat(100))
|
||||||
await expect(readSource('big.ts', 10)).rejects.toThrow(/10-byte limit/)
|
await expect(readSource('big.ts', 10)).rejects.toMatchObject({
|
||||||
|
message: 'source "big.ts" exceeds the 10-byte limit',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('counts the complete UTF-8 byte length at the configured boundary', async () => {
|
it('counts the complete UTF-8 byte length at the configured boundary', async () => {
|
||||||
|
|||||||
@@ -195,6 +195,50 @@ describe('lsp-local provider resolution', () => {
|
|||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('waits for aborted sibling executable lookups before setup rejects', async () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
await ctx.plugin(Lsp)
|
||||||
|
await ctx.plugin(LocalSubprocessService)
|
||||||
|
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||||
|
const slowStarted = Promise.withResolvers<undefined>()
|
||||||
|
const slowAborted = Promise.withResolvers<undefined>()
|
||||||
|
const releaseCleanup = Promise.withResolvers<undefined>()
|
||||||
|
vi.spyOn(ctx.subprocess, 'resolveExecutable').mockImplementation(async (command, _env, signal) => {
|
||||||
|
if (signal === undefined) throw new Error('missing setup signal')
|
||||||
|
if (command === 'slow-lsp') {
|
||||||
|
return await new Promise<string>((_resolve, reject) => {
|
||||||
|
const onAbort = (): void => {
|
||||||
|
slowAborted.resolve(undefined)
|
||||||
|
void releaseCleanup.promise.then(() => {
|
||||||
|
reject(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
signal.addEventListener('abort', onAbort, { once: true })
|
||||||
|
slowStarted.resolve(undefined)
|
||||||
|
if (signal.aborted) onAbort()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await slowStarted.promise
|
||||||
|
throw new Error('lookup failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ctx.plugin(LspLocal, {
|
||||||
|
servers: {
|
||||||
|
slow: { command: 'slow-lsp', extensionToLanguage: { '.ts': 'typescript' } },
|
||||||
|
failing: { command: 'failing-lsp', extensionToLanguage: { '.js': 'javascript' } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await slowAborted.promise
|
||||||
|
let settled = false
|
||||||
|
void loading.then(() => { settled = true }, () => { settled = true })
|
||||||
|
await new Promise<void>((resolve) => { setImmediate(resolve) })
|
||||||
|
expect(settled).toBe(false)
|
||||||
|
|
||||||
|
releaseCleanup.resolve(undefined)
|
||||||
|
await expect(loading).rejects.toThrow('lookup failed')
|
||||||
|
await ctx.fiber.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
it('aborts executable resolution when disposed during setup', async () => {
|
it('aborts executable resolution when disposed during setup', async () => {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(Lsp)
|
await ctx.plugin(Lsp)
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ export class LocalPtySession implements PtyBackendSession {
|
|||||||
}
|
}
|
||||||
this.activeDeadlineTimer = setTimeout(() => {
|
this.activeDeadlineTimer = setTimeout(() => {
|
||||||
if (this.active === operation) {
|
if (this.active === operation) {
|
||||||
this.settleActive('timeout', this.activeWrite?.operation === operation)
|
this.settleActive('timeout', this.activeWrite?.operation === operation || this.interrupting === operation)
|
||||||
}
|
}
|
||||||
}, this.config.timeoutMs)
|
}, this.config.timeoutMs)
|
||||||
void this.beginSend(operation, request)
|
void this.beginSend(operation, request)
|
||||||
|
|||||||
@@ -365,11 +365,11 @@ describe('LocalPtySession readiness and output', () => {
|
|||||||
expect(operation.cancel()).toBe(true)
|
expect(operation.cancel()).toBe(true)
|
||||||
|
|
||||||
terminal.emitData('\x1b]133;D;130\x07dsh> ')
|
terminal.emitData('\x1b]133;D;130\x07dsh> ')
|
||||||
await vi.advanceTimersByTimeAsync(10)
|
await vi.advanceTimersByTimeAsync(100)
|
||||||
|
expect((await operation.done).waitReason).toBe('timeout')
|
||||||
expect(() => session.startSend({ text: 'successor', submit: true })).toThrow('active send')
|
expect(() => session.startSend({ text: 'successor', submit: true })).toThrow('active send')
|
||||||
signalGate.resolve(undefined)
|
signalGate.resolve(undefined)
|
||||||
await vi.advanceTimersByTimeAsync(10)
|
await vi.advanceTimersByTimeAsync(0)
|
||||||
await operation.done
|
|
||||||
expect(inspector.groups).toContainEqual([456, 'SIGINT'])
|
expect(inspector.groups).toContainEqual([456, 'SIGINT'])
|
||||||
expect(inspector.groups).not.toContainEqual([789, 'SIGINT'])
|
expect(inspector.groups).not.toContainEqual([789, 'SIGINT'])
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user