mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 10:02:04 +08:00
* fix(cli): stabilize Windows CI tests and rebalance slow shards Three Windows-only instabilities in the CLI unit suite: 1. httpapi-instance-route-auth.test.ts failed with an uncaught "Invalid handle" error. The test's ConfigProvider.layer( fromUnknown(...)) replaced the ambient config provider, blinding KILO_EXPERIMENTAL_DISABLE_FILEWATCHER=true that CI/preload sets. With the flag hidden, the @parcel/watcher Windows backend subscribed on the temp repo's .git; the tmpdir fixture then deleted that directory while the never-disposed per-test runtime still held the subscription, and CreateFileW failed with the hardcoded "Invalid handle" (napi rejection with no JS stack). Add the disable-filewatcher flag to every test config map that boots instances via the HttpApi app (instance-route-auth, cors, ui, exercise backend, kilo-edit, memory). 2. config-overlay.test.ts intermittently returned HTTP 500 on Windows. Filesystem.write's atomic temp-file+rename had no retry for Windows transient locked-file errors (EPERM/EACCES/EBUSY) from Defender/indexer and the detached background plugin install racing the rename in the same tmpdir. Mirror the proven cleanup.ts locked-error retry pattern with a short backoff, Windows-only. 3. Windows shards were badly imbalanced: the sharder weighted files by byte size, which concentrated every slow spawn/FS/lock-heavy file (snapshot, prompt, provider, run-process, instance-bootstrap, httpapi-session) into one shard (~612s vs ~356s siblings), and the resulting contention forced whole-file retries that doubled cost. Add TestShard.timedWeight and a committed test-timings.json seeded from CI junit data so shards balance by measured runtime (spread collapses from ~200s to ~18s) and contention-prone files spread across shards. Platforms without manifest entries fall back to size weighting. * fix(cli): skip stale manifest entries in timed shard weighting Bun.file().size returns 0 (never throws) for missing paths, so the try/catch in timedWeight was dead code and stale/renamed manifest entries added their time to the scale numerator with zero size, inflating the size-to-time ratio that estimates unknown files. Skip entries with a non-positive on-disk size instead of catching a throw that never happens. * revert(cli): drop hardcoded test-timings manifest The committed test-timings.json (482 entries) was a maintenance burden: it goes stale as tests are added/renamed and no size-based heuristic can replace it (slow subprocess outliers like run-process.test.ts are 7kb but 112s, 10x the runtime-per-byte of other files). Revert the timing-weighted sharding to the prior size-based LPT. The Windows reliability fixes (ConfigProvider filewatcher flag + Filesystem.write locked-file retry) remain and are what eliminate the failures and the ~360s of retry overhead that dominated the 12m50s shard. A maintainable runtime-based rebalance (self-updating CI cache fed from the junit artifacts CI already uploads) is a separate follow-up.
151 lines
5.2 KiB
TypeScript
151 lines
5.2 KiB
TypeScript
import { ConfigProvider, Effect, Layer } from "effect"
|
|
import { HttpRouter } from "effect/unstable/http"
|
|
import { parse } from "./assertions"
|
|
import { runtime, type Runtime } from "./runtime"
|
|
import type { ActiveScenario, BackendApp, CallResult, CaptureMode, SeededContext } from "./types"
|
|
|
|
type CallOptions = {
|
|
auth?: {
|
|
password?: string
|
|
username?: string
|
|
}
|
|
}
|
|
|
|
export function call(scenario: ActiveScenario, ctx: SeededContext<unknown>, options: CallOptions = {}) {
|
|
return Effect.promise(async () =>
|
|
capture(await app(await runtime(), options).request(toRequest(scenario, ctx)), scenario.capture),
|
|
)
|
|
}
|
|
|
|
export function callAuthProbe(scenario: ActiveScenario, credentials: "missing" | "valid" = "missing") {
|
|
return Effect.promise(async () => {
|
|
const controller = new AbortController()
|
|
return Promise.race([
|
|
Promise.resolve(
|
|
app(await runtime(), { auth: { password: "secret" } }).request(
|
|
toAuthProbeRequest(scenario, credentials, controller.signal),
|
|
),
|
|
).then((response) => capture(response, scenario.capture)),
|
|
Bun.sleep(1_000).then(() => {
|
|
controller.abort("auth probe timed out")
|
|
return {
|
|
status: 0,
|
|
contentType: "",
|
|
text: "auth probe timed out",
|
|
body: undefined,
|
|
timedOut: true,
|
|
}
|
|
}),
|
|
])
|
|
})
|
|
}
|
|
|
|
type CachedApp = BackendApp & { readonly dispose: () => Promise<void> }
|
|
|
|
const appCache: Partial<Record<string, CachedApp>> = {}
|
|
|
|
export async function disposeApps() {
|
|
const apps = Object.values(appCache)
|
|
for (const key of Object.keys(appCache)) delete appCache[key]
|
|
await Promise.all(apps.flatMap((app) => (app === undefined ? [] : [app.dispose()])))
|
|
}
|
|
|
|
function app(modules: Runtime, options: CallOptions) {
|
|
const username = options.auth?.username
|
|
const password = options.auth?.password
|
|
const cacheKey = `${username ?? ""}:${password ?? ""}`
|
|
if (appCache[cacheKey]) return appCache[cacheKey]
|
|
|
|
const web = HttpRouter.toWebHandler(
|
|
modules.HttpApiApp.routes.pipe(
|
|
Layer.provide(
|
|
// kilocode_change start - keep the filewatcher-disable flag visible (see httpapi-instance-route-auth.test.ts)
|
|
ConfigProvider.layer(
|
|
ConfigProvider.fromUnknown({
|
|
KILO_SERVER_PASSWORD: password,
|
|
KILO_SERVER_USERNAME: username,
|
|
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: process.env.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER ?? "true",
|
|
}),
|
|
),
|
|
// kilocode_change end
|
|
),
|
|
),
|
|
{ disableLogger: true, memoMap: modules.memoMap },
|
|
)
|
|
return (appCache[cacheKey] = {
|
|
dispose: web.dispose,
|
|
request(input: string | URL | Request, init?: RequestInit) {
|
|
return web.handler(
|
|
input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
|
|
modules.HttpApiApp.context,
|
|
)
|
|
},
|
|
})
|
|
}
|
|
|
|
function toRequest(scenario: ActiveScenario, ctx: SeededContext<unknown>) {
|
|
const spec = scenario.request(ctx, ctx.state)
|
|
return new Request(new URL(spec.path, "http://localhost"), {
|
|
method: scenario.method,
|
|
headers: spec.body === undefined ? spec.headers : { "content-type": "application/json", ...spec.headers },
|
|
body: spec.body === undefined ? undefined : JSON.stringify(spec.body),
|
|
})
|
|
}
|
|
|
|
function toAuthProbeRequest(scenario: ActiveScenario, credentials: "missing" | "valid", signal: AbortSignal) {
|
|
const spec = scenario.authProbe ?? {
|
|
path: authProbePath(scenario.path),
|
|
body: scenario.method === "GET" ? undefined : {},
|
|
}
|
|
const headers = {
|
|
...(spec.body === undefined ? {} : { "content-type": "application/json" }),
|
|
...spec.headers,
|
|
...(credentials === "valid" ? { authorization: basic("kilo", "secret") } : {}), // kilocode_change
|
|
}
|
|
return new Request(new URL(spec.path, "http://localhost"), {
|
|
method: scenario.method,
|
|
headers,
|
|
body: spec.body === undefined ? undefined : JSON.stringify(spec.body),
|
|
signal,
|
|
})
|
|
}
|
|
|
|
function basic(username: string, password: string) {
|
|
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
|
}
|
|
|
|
function authProbePath(path: string) {
|
|
return path
|
|
.replace(/\{([^}]+)\}/g, (_match, key: string) => `auth_${key}`)
|
|
.replace(/:([^/]+)/g, (_match, key: string) => `auth_${key}`)
|
|
}
|
|
|
|
async function capture(response: Response, mode: CaptureMode): Promise<CallResult> {
|
|
const text = mode === "stream" ? await captureStream(response) : await response.text()
|
|
return {
|
|
status: response.status,
|
|
contentType: response.headers.get("content-type") ?? "",
|
|
text,
|
|
body: parse(text),
|
|
timedOut: false,
|
|
}
|
|
}
|
|
|
|
async function captureStream(response: Response) {
|
|
if (!response.body) return ""
|
|
const reader = response.body.getReader()
|
|
const read = reader.read().then(
|
|
(result) => ({ result }),
|
|
(error: unknown) => ({ error }),
|
|
)
|
|
const winner = await Promise.race([read, Bun.sleep(1_000).then(() => ({ timeout: true }))])
|
|
if ("timeout" in winner) {
|
|
await reader.cancel("timed out waiting for stream chunk").catch(() => undefined)
|
|
throw new Error("timed out waiting for stream chunk")
|
|
}
|
|
if ("error" in winner) throw winner.error
|
|
await reader.cancel().catch(() => undefined)
|
|
if (winner.result.done) return ""
|
|
return new TextDecoder().decode(winner.result.value)
|
|
}
|