chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,477 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user