fix(llm-mock-server): harden fault boundaries

This commit is contained in:
Yichen Jiang
2026-07-25 22:45:58 +08:00
parent 5446714177
commit badf7d1c63
10 changed files with 109 additions and 31 deletions
+13 -3
View File
@@ -3,7 +3,7 @@
* @module @deepseek-ai/dsh-llm-mock-server/cli
*/
import { MOCK_LLM_BEHAVIORS } from './index.ts'
import { MAX_MOCK_LLM_TIMER_DELAY_MS, MOCK_LLM_BEHAVIORS } from './index.ts'
import type {
ConcreteMockLlmBehavior,
MockLlmBehavior,
@@ -18,7 +18,7 @@ export const CONNECTION_REFUSED_BEHAVIOR = 'connection_refused'
export interface MockLlmCliConfig {
/** Server options after removing the lifecycle-only `connection_refused` entry. */
readonly server: MockLlmServerOptions
/** Delay before binding the model port; zero starts immediately. */
/** Delay before binding the model port; an integer from zero through the Node timer maximum. */
readonly listenDelayMs: number
/** Whether the original sequence requested a true pre-listen refusal phase. */
readonly startsUnavailable: boolean
@@ -77,6 +77,14 @@ function numberValue(option: string, value: string): number {
return parsed
}
function boundedIntegerValue(option: string, value: string, min: number, max: number): number {
const parsed = numberValue(option, value)
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
throw new Error(`dsh-llm-mock-server: ${option} must be an integer between ${min} and ${max}`)
}
return parsed
}
function parseSequence(raw: string): { startsUnavailable: boolean; sequence: MockLlmBehavior[] } {
const entries = raw.split(',').map(entry => entry.trim())
if (entries.some(entry => entry.length === 0)) {
@@ -154,7 +162,9 @@ export function parseMockLlmCliArgs(argv: readonly string[]): MockLlmCliParseRes
case '--host': host = value; break
case '--port': port = numberValue(option, value); break
case '--api-key': apiKey = value; break
case '--listen-delay-ms': listenDelayMs = numberValue(option, value); break
case '--listen-delay-ms':
listenDelayMs = boundedIntegerValue(option, value, 0, MAX_MOCK_LLM_TIMER_DELAY_MS)
break
case '--seed': randomSeed = numberValue(option, value); break
case '--random-weights': randomWeights = parseRandomWeights(value); break
case '--success-text': successText = value; break
+23 -8
View File
@@ -9,7 +9,7 @@
import { createServer } from 'node:http'
import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http'
import { randomBytes } from 'node:crypto'
import type { AddressInfo } from 'node:net'
import { isIP, type AddressInfo } from 'node:net'
import { setTimeout as delay } from 'node:timers/promises'
/** Request-scoped behaviors accepted by {@link startMockLlmServer}. */
@@ -69,6 +69,9 @@ export const DEFAULT_MOCK_LLM_RANDOM_WEIGHTS: Readonly<MockLlmRandomWeights> = O
malformed_json: 1,
})
/** Largest millisecond delay accepted by Node timers without truncation. */
export const MAX_MOCK_LLM_TIMER_DELAY_MS = 2_147_483_647
/** How one accepted request ended at the mock boundary. */
export type MockLlmRequestOutcome = 'completed' | 'reset' | 'stalled' | 'client_closed' | 'server_error'
@@ -186,7 +189,6 @@ interface ResolvedOptions {
readonly onEvent?: (event: MockLlmServerEvent) => void
}
const MAX_TIMER_DELAY_MS = 2_147_483_647
const DEFAULT_SUCCESS_TEXT = 'mock response recovered'
const DEFAULT_PARTIAL_TEXT = 'discarded partial response'
const DEFAULT_REASONING_TEXT = 'mock reasoning'
@@ -203,14 +205,24 @@ function resolveOptions(options: MockLlmServerOptions): ResolvedOptions {
const host = options.host ?? '127.0.0.1'
const port = boundedInteger('port', options.port ?? 0, 0, 65_535)
const chunkSize = boundedInteger('chunkSize', options.chunkSize ?? 8, 1, Number.MAX_SAFE_INTEGER)
const chunkDelayMs = boundedInteger('chunkDelayMs', options.chunkDelayMs ?? 25, 0, MAX_TIMER_DELAY_MS)
const chunkDelayMs = boundedInteger(
'chunkDelayMs',
options.chunkDelayMs ?? 25,
0,
MAX_MOCK_LLM_TIMER_DELAY_MS,
)
const disconnectDelayMs = boundedInteger(
'disconnectDelayMs',
options.disconnectDelayMs ?? 10,
0,
MAX_TIMER_DELAY_MS,
MAX_MOCK_LLM_TIMER_DELAY_MS,
)
const retryAfterMs = boundedInteger(
'retryAfterMs',
options.retryAfterMs ?? 1_000,
1,
MAX_MOCK_LLM_TIMER_DELAY_MS,
)
const retryAfterMs = boundedInteger('retryAfterMs', options.retryAfterMs ?? 1_000, 1, MAX_TIMER_DELAY_MS)
const randomSeed = boundedInteger(
'randomSeed',
options.randomSeed ?? randomBytes(4).readUInt32LE(0),
@@ -285,8 +297,9 @@ function emit(options: ResolvedOptions, event: MockLlmServerEvent): void {
}
async function readJsonBody(request: IncomingMessage): Promise<unknown> {
let body = ''
for await (const chunk of request) body += Buffer.from(chunk).toString('utf8')
const chunks: Buffer[] = []
for await (const chunk of request) chunks.push(Buffer.from(chunk as Uint8Array))
const body = Buffer.concat(chunks).toString('utf8')
return body.length === 0 ? undefined : JSON.parse(body)
}
@@ -320,6 +333,7 @@ function finishRecord(
record: MockLlmRequestRecord,
outcome: MockLlmRequestOutcome,
): void {
if (record.outcome !== undefined) return
record.outcome = outcome
emit(options, {
type: 'result',
@@ -713,8 +727,9 @@ export async function startMockLlmServer(options: MockLlmServerOptions): Promise
})
const address = server.address() as AddressInfo
const advertisedHost = isIP(resolved.host) === 6 ? `[${resolved.host}]` : resolved.host
return {
baseURL: `http://${resolved.host}:${address.port}`,
baseURL: `http://${advertisedHost}:${address.port}`,
port: address.port,
randomSeed: resolved.randomSeed,
requests,