Files
2026-07-13 12:47:58 +08:00

478 lines
12 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { CommandHandle } from '../../../src/sandbox/commands/commandHandle'
type EventKind = 'stdout' | 'stderr' | 'pty'
function createEvents(kind: EventKind): AsyncIterable<any> {
async function* events() {
if (kind === 'pty') {
yield {
event: {
event: {
case: 'data',
value: {
output: {
case: 'pty',
value: new Uint8Array([1, 2, 3]),
},
},
},
},
}
} else {
yield {
event: {
event: {
case: 'data',
value: {
output: {
case: kind,
value: new TextEncoder().encode(kind),
},
},
},
},
}
}
yield {
event: {
event: {
case: 'end',
value: {
exitCode: 0,
error: undefined,
},
},
},
}
}
return events()
}
function dataEvent(kind: 'stdout' | 'stderr', value: Uint8Array) {
return {
event: {
event: {
case: 'data',
value: {
output: {
case: kind,
value,
},
},
},
},
}
}
function endEvent(exitCode = 0) {
return {
event: {
event: {
case: 'end',
value: {
exitCode,
error: undefined,
},
},
},
}
}
// An async iterable whose events are delivered on demand. Lets a test hold the
// handle's event loop blocked on `next()` (idle between bursts) and then push a
// late event to simulate stdout arriving after `disconnect()` — the transport
// condition that triggers the production leak. `return()` (called when the
// handle's `for await` breaks) unblocks any pending read, mirroring the stream
// being torn down from the client side.
function createControllableEvents() {
const queue: any[] = []
let pending: ((result: IteratorResult<any>) => void) | undefined
let closed = false
const iterator: AsyncIterator<any> = {
next() {
if (queue.length > 0) {
return Promise.resolve({ value: queue.shift(), done: false })
}
if (closed) {
return Promise.resolve({ value: undefined, done: true })
}
return new Promise((resolve) => {
pending = resolve
})
},
return() {
closed = true
if (pending) {
const resolve = pending
pending = undefined
resolve({ value: undefined, done: true })
}
return Promise.resolve({ value: undefined, done: true })
},
}
return {
events: { [Symbol.asyncIterator]: () => iterator } as AsyncIterable<any>,
push(event: any) {
if (pending) {
const resolve = pending
pending = undefined
resolve({ value: event, done: false })
} else {
queue.push(event)
}
},
}
}
describe('CommandHandle', () => {
it.each<EventKind>(['stdout', 'stderr', 'pty'])(
'wait awaits async %s callbacks',
async (kind) => {
let callbackStarted = false
let releaseCallback: (() => void) | undefined
const callbackBlocked = new Promise<void>((resolve) => {
releaseCallback = resolve
})
const callback = async () => {
callbackStarted = true
await callbackBlocked
}
const handle = new CommandHandle(
1,
() => {},
async () => true,
createEvents(kind),
kind === 'stdout' ? callback : undefined,
kind === 'stderr' ? callback : undefined,
kind === 'pty' ? callback : undefined
)
let waitResolved = false
const waitPromise = handle.wait().then(() => {
waitResolved = true
})
await vi.waitFor(() => {
expect(callbackStarted).toBe(true)
})
expect(waitResolved).toBe(false)
releaseCallback?.()
await waitPromise
expect(waitResolved).toBe(true)
}
)
it('decodes multibyte characters split across chunks', async () => {
const emojiBytes = new TextEncoder().encode('😀')
async function* events() {
yield dataEvent(
'stdout',
new Uint8Array([
...new TextEncoder().encode('a'),
...emojiBytes.slice(0, 2),
])
)
yield dataEvent(
'stdout',
new Uint8Array([
...emojiBytes.slice(2),
...new TextEncoder().encode('b'),
])
)
yield dataEvent('stderr', emojiBytes.slice(0, 3))
yield dataEvent('stderr', emojiBytes.slice(3))
yield endEvent()
}
const stdoutChunks: string[] = []
const handle = new CommandHandle(
1,
() => {},
async () => true,
events(),
(out) => {
stdoutChunks.push(out)
}
)
const result = await handle.wait()
expect(result.stdout).toBe('a😀b')
expect(result.stderr).toBe('😀')
expect(result.stdout).not.toContain('')
expect(result.stderr).not.toContain('')
expect(stdoutChunks.join('')).toBe('a😀b')
})
it('replaces incomplete trailing utf-8 sequences at the end of the stream', async () => {
const emojiBytes = new TextEncoder().encode('😀')
async function* events() {
yield dataEvent(
'stdout',
new Uint8Array([
...new TextEncoder().encode('a'),
...emojiBytes.slice(0, 2),
])
)
yield endEvent()
}
const handle = new CommandHandle(
1,
() => {},
async () => true,
events()
)
const result = await handle.wait()
expect(result.stdout).toBe('a')
})
it('flushes incomplete trailing utf-8 sequences when the stream closes without an end event', async () => {
const emojiBytes = new TextEncoder().encode('😀')
async function* events() {
yield dataEvent(
'stdout',
new Uint8Array([
...new TextEncoder().encode('a'),
...emojiBytes.slice(0, 2),
])
)
}
const stdoutChunks: string[] = []
const handle = new CommandHandle(
1,
() => {},
async () => true,
events(),
(out) => {
stdoutChunks.push(out)
}
)
// No end event arrives, so wait() rejects, but the buffered bytes must
// still be flushed to the stdout callback as a replacement character.
await expect(handle.wait()).rejects.toThrow()
expect(stdoutChunks.join('')).toBe('a')
})
it('flushes incomplete trailing utf-8 sequences when the stream errors', async () => {
const emojiBytes = new TextEncoder().encode('😀')
async function* events() {
yield dataEvent(
'stdout',
new Uint8Array([
...new TextEncoder().encode('a'),
...emojiBytes.slice(0, 2),
])
)
throw new Error('stream died')
}
const stdoutChunks: string[] = []
const handle = new CommandHandle(
1,
() => {},
async () => true,
events(),
(out) => {
stdoutChunks.push(out)
}
)
// The stream errors before an end event arrives, so wait() rejects, but the
// buffered bytes must still be flushed to the stdout callback as a
// replacement character.
await expect(handle.wait()).rejects.toThrow()
expect(stdoutChunks.join('')).toBe('a')
})
it('onStdout stops firing after await disconnect()', async () => {
const controllable = createControllableEvents()
const chunks: string[] = []
const handle = new CommandHandle(
1,
() => {},
async () => true,
controllable.events,
(out) => {
chunks.push(out)
}
)
// First burst is delivered to the live subscriber.
controllable.push(dataEvent('stdout', new TextEncoder().encode('a')))
await vi.waitFor(() => {
expect(chunks).toEqual(['a'])
})
// Disconnect while the event loop is idle (blocked on the next read), then
// push a late event — as if stdout arrived after the abort but before the
// stream was torn down. The late event must never reach the callback.
await handle.disconnect()
controllable.push(dataEvent('stdout', new TextEncoder().encode('b')))
await new Promise((r) => setTimeout(r, 0))
expect(chunks).toEqual(['a'])
// Any further output is ignored too.
controllable.push(dataEvent('stdout', new TextEncoder().encode('c')))
await new Promise((r) => setTimeout(r, 0))
expect(chunks).toEqual(['a'])
})
it('disconnect() resolves promptly when a stream is idle', async () => {
// An idle long-running command (e.g. `sleep`) produces no further output,
// so the event handler stays blocked on the next read. disconnect() must
// not wait for that read and must resolve right away.
const controllable = createControllableEvents()
const handle = new CommandHandle(
1,
() => {},
async () => true,
controllable.events,
() => {}
)
// No event is ever pushed; this would hang if disconnect() awaited the
// event handler.
await handle.disconnect()
})
it('disconnect() does not block on an in-flight callback', async () => {
const controllable = createControllableEvents()
let callbackStarted = false
let releaseCallback: (() => void) | undefined
const callbackBlocked = new Promise<void>((resolve) => {
releaseCallback = resolve
})
const handle = new CommandHandle(
1,
() => {},
async () => true,
controllable.events,
async () => {
callbackStarted = true
await callbackBlocked
}
)
controllable.push(dataEvent('stdout', new TextEncoder().encode('a')))
await vi.waitFor(() => {
expect(callbackStarted).toBe(true)
})
// The callback is still in flight; disconnect() must resolve without
// waiting for it to settle.
await handle.disconnect()
// Releasing the callback afterwards is harmless.
releaseCallback?.()
})
it('disconnect() resolves when called from inside a callback', async () => {
const controllable = createControllableEvents()
let disconnectReturned = false
const handle: CommandHandle = new CommandHandle(
1,
() => {},
async () => true,
controllable.events,
async () => {
await handle.disconnect()
disconnectReturned = true
}
)
controllable.push(dataEvent('stdout', new TextEncoder().encode('a')))
await vi.waitFor(() => {
expect(disconnectReturned).toBe(true)
})
})
it('records the exit code when disconnected before an end event with trailing bytes', async () => {
const controllable = createControllableEvents()
const emojiBytes = new TextEncoder().encode('😀')
const chunks: string[] = []
const handle = new CommandHandle(
1,
() => {},
async () => true,
controllable.events,
(out) => {
chunks.push(out)
}
)
// First chunk leaves an incomplete multibyte sequence buffered in the
// decoder, so the end event will flush a trailing replacement character.
controllable.push(
dataEvent(
'stdout',
new Uint8Array([
...new TextEncoder().encode('a'),
...emojiBytes.slice(0, 2),
])
)
)
await vi.waitFor(() => {
expect(chunks).toEqual(['a'])
})
// Disconnect, then the process exits. The end event carries a flushed
// chunk; wait() must still surface the exit code instead of failing as if
// the process never produced a result.
await handle.disconnect()
controllable.push(endEvent(0))
const result = await handle.wait()
expect(result.exitCode).toBe(0)
expect(result.stdout).toBe('a')
// The flushed chunk was produced after disconnect, so it must not reach
// the live callback.
expect(chunks).toEqual(['a'])
})
it('surfaces an async callback error through wait()', async () => {
async function* events() {
yield dataEvent('stdout', new TextEncoder().encode('a'))
yield endEvent()
}
const handle = new CommandHandle(
1,
() => {},
async () => true,
events(),
async () => {
throw new Error('callback failed')
}
)
await expect(handle.wait()).rejects.toThrow('callback failed')
})
})