Files
kilocode/packages/opencode/test/server/httpapi-exercise/backend.ts
T
Marius a1ad65e522 fix(cli): stabilize Windows CI tests and rebalance slow shards (#12723)
* 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.
2026-07-31 13:27:34 +02:00

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)
}