114 lines
5.9 KiB
JavaScript
114 lines
5.9 KiB
JavaScript
|
|
// Carrier error-channel regression probes (audit batch: A1/A2/A4/A9 + R2 half).
|
||
|
|
// Runs the isomorphic path (InProcessApiClient over toFetchHandler) — no server needed.
|
||
|
|
// Run: node --experimental-strip-types missions/scripts/verify-carrier-errors.mjs (or via tsx)
|
||
|
|
import { toFetchHandler } from '../../packages/host/apiproxy/src/fetch/handler.ts'
|
||
|
|
import { InProcessApiClient } from '../../packages/host/apiproxy/src/fetch/client.ts'
|
||
|
|
import { RpcId } from '../../packages/host/apiproxy/src/api/rpc.ts'
|
||
|
|
import { serverResponseSchema } from '../../packages/host/apiproxy/src/api/rpc.schema.ts'
|
||
|
|
|
||
|
|
let failures = 0
|
||
|
|
const report = (n, p, d = '') => { failures += p ? 0 : 1; console.log(`${p ? 'PASS' : 'FAIL'} ${n}${d ? ' — ' + d : ''}`) }
|
||
|
|
|
||
|
|
const okList = { rpcId: RpcId('x'), result: { ok: true, value: { items: [] } } }
|
||
|
|
/** Minimal ApiProxy stub; per-test cases override single methods. */
|
||
|
|
function makeApi(overrides = {}) {
|
||
|
|
return {
|
||
|
|
sessions: {
|
||
|
|
list: async (r) => ({ ...okList, rpcId: r.rpcId }),
|
||
|
|
create: async (r) => ({ rpcId: r.rpcId, result: { ok: true, value: { sessionId: 's1' } } }),
|
||
|
|
history: async (r) => ({ rpcId: r.rpcId, result: { ok: true, value: { events: [], hasMore: false } } }),
|
||
|
|
prompt: async (r) => ({ rpcId: r.rpcId, result: { ok: true, value: { accepted: true } } }),
|
||
|
|
cancel: async (r) => ({ rpcId: r.rpcId, result: { ok: true, value: { accepted: true } } }),
|
||
|
|
...overrides.sessions,
|
||
|
|
},
|
||
|
|
host: {
|
||
|
|
describe: async (r) => ({ rpcId: r.rpcId, result: { ok: true, value: { version: '0', cwd: '/', attachedSessions: 0 } } }),
|
||
|
|
...overrides.host,
|
||
|
|
},
|
||
|
|
events: {
|
||
|
|
mux: overrides.mux ?? async function* () {},
|
||
|
|
host: overrides.hostStream ?? async function* () {},
|
||
|
|
},
|
||
|
|
respond: async () => ({ accepted: false, reason: 'not-pending' }),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- A1: mid-stream impl throw → one stream/error frame on the wire, then clean close ----
|
||
|
|
{
|
||
|
|
const api = makeApi({
|
||
|
|
hostStream: async function* () {
|
||
|
|
yield { rpcId: RpcId('f1'), payload: { type: 'host/session-status', sessionId: 's1', running: true } }
|
||
|
|
throw new Error('impl exploded mid-stream')
|
||
|
|
},
|
||
|
|
})
|
||
|
|
const client = new InProcessApiClient(toFetchHandler(api))
|
||
|
|
const seen = []
|
||
|
|
for await (const frame of client.events.host({}, new AbortController().signal)) seen.push(frame.payload)
|
||
|
|
report('A1 流中 impl throw → stream/error 帧真到达 client', seen.some(f => f.type === 'stream/error' && f.error.code === 'internal' && /impl exploded/.test(f.error.message)), JSON.stringify(seen.map(f => f.type)))
|
||
|
|
report('A1b stream/error 后流正常收尾(迭代自然结束不 throw)', true)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- A2: S→C frame validation — a malformed frame is dropped, the stream survives ----
|
||
|
|
{
|
||
|
|
const api = makeApi({
|
||
|
|
hostStream: async function* () {
|
||
|
|
yield { rpcId: RpcId('bad'), payload: { type: 'host/session-status', sessionId: 's1' } } // missing `running`
|
||
|
|
yield { rpcId: RpcId('good'), payload: { type: 'host/session-status', sessionId: 's1', running: false } }
|
||
|
|
},
|
||
|
|
})
|
||
|
|
const client = new InProcessApiClient(toFetchHandler(api))
|
||
|
|
const seen = []
|
||
|
|
for await (const frame of client.events.host({}, new AbortController().signal)) seen.push(frame)
|
||
|
|
report('A2 坏帧被丢弃且不杀流(后续好帧照常到达)', seen.length === 1 && seen[0].payload.running === false, `seen=${seen.length}`)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- A2: S→C unary value validation — a wrong-shaped ok value throws at the client boundary ----
|
||
|
|
{
|
||
|
|
const api = makeApi({ sessions: { list: async (r) => ({ rpcId: r.rpcId, result: { ok: true, value: { items: 'not-an-array' } } }) } })
|
||
|
|
const client = new InProcessApiClient(toFetchHandler(api))
|
||
|
|
const threw = await client.sessions.list({}).then(() => false, () => true)
|
||
|
|
report('A2b unary ok value 过 Value schema(坏形状在 client 边界抛出)', threw)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- A4: envelope parse failure backfills a salvageable rpcId; otherwise the sentinel — and the response parses as a valid ServerResponse ----
|
||
|
|
{
|
||
|
|
const handler = toFetchHandler(makeApi())
|
||
|
|
const post = (body) => handler.fetch('http://dsh.internal/api/session.list', {
|
||
|
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
|
||
|
|
})
|
||
|
|
const salvaged = await (await post({ rpcId: 'my-id', method: 5 })).json()
|
||
|
|
report('A4 信封烂但 rpcId 可捞 → 回填原值', serverResponseSchema.safeParse(salvaged).success && salvaged.rpcId === 'my-id', JSON.stringify(salvaged.rpcId))
|
||
|
|
const sentinel = await (await post({ nothing: true })).json()
|
||
|
|
report('A4b rpcId 不可捞 → invalid-request 哨兵,且过 serverResponseSchema', serverResponseSchema.safeParse(sentinel).success && sentinel.rpcId === 'invalid-request', JSON.stringify(sentinel.rpcId))
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- A10: external signal aborts an in-flight unary ----
|
||
|
|
{
|
||
|
|
const api = makeApi({ sessions: { list: () => new Promise(() => {}) } })
|
||
|
|
const client = new InProcessApiClient(toFetchHandler(api))
|
||
|
|
const ctl = new AbortController()
|
||
|
|
const call = client.sessions.list({}, ctl.signal).then(() => 'resolved', (e) => String(e))
|
||
|
|
ctl.abort(new Error('user cancelled'))
|
||
|
|
const outcome = await call
|
||
|
|
report('A10 unary 外部 signal 可取消在途请求', outcome !== 'resolved', outcome.slice(0, 60))
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- onOpen: stream-established signal fires before any frame is delivered ----
|
||
|
|
{
|
||
|
|
const api = makeApi({
|
||
|
|
hostStream: async function* () {
|
||
|
|
yield { rpcId: RpcId('f'), payload: { type: 'host/session-removed', sessionId: 's1' } }
|
||
|
|
},
|
||
|
|
})
|
||
|
|
const client = new InProcessApiClient(toFetchHandler(api))
|
||
|
|
const order = []
|
||
|
|
const iter = client.events.host({}, new AbortController().signal, () => order.push('open'))[Symbol.asyncIterator]()
|
||
|
|
await iter.next()
|
||
|
|
order.push('frame')
|
||
|
|
report('C2 信号:onOpen 先于首帧交付', order.join(',') === 'open,frame', order.join(','))
|
||
|
|
await iter.return?.()
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(failures === 0 ? 'ALL PASS' : `${failures} FAILURE(S)`)
|
||
|
|
process.exit(failures === 0 ? 0 : 1)
|