|
1 | | -import { expect, test } from 'vitest'; |
| 1 | +import { beforeEach, describe, expect, test } from 'vitest'; |
2 | 2 | import assert from 'node:assert/strict'; |
3 | | -import { AppError } from '@agent-device/kernel/errors'; |
| 3 | +import { AppError, isRequestCanceledError } from '@agent-device/kernel/errors'; |
| 4 | +import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo'; |
4 | 5 | import { IOS_DEVICE, IOS_SIMULATOR, MACOS_DEVICE } from './device-fixtures.ts'; |
| 6 | +import { appleRunnerTestHost } from '../test-host.ts'; |
| 7 | +import type { ExecOptions } from '../host.ts'; |
5 | 8 | import { |
6 | 9 | COLD_TOOLCHAIN_PROBE_TIMEOUT_MS, |
7 | 10 | diffComparableRunnerCacheMetadata, |
@@ -253,58 +256,182 @@ test('a timed-out probe leaves the toolchain unavailable instead of a comparable |
253 | 256 |
|
254 | 257 | // Apple's syspolicyd signature scan blocks the first xcodebuild/xcrun exec |
255 | 258 | // after a fresh macOS host boots for roughly 18 to 19 seconds; the immediate |
256 | | -// next exec of the same tool is instant (#2422). These two cases exercise |
257 | | -// the resulting one-retry policy without waiting on a real cold-start stall. |
258 | | -// They use device fixtures untouched by the tests above so the toolchain |
259 | | -// fingerprint cache starts empty for each. |
| 259 | +// next exec of the same tool is instant (#2422). These cases exercise the |
| 260 | +// resulting one-retry policy, and the budget that bounds it, without waiting |
| 261 | +// on a real cold-start stall: the fake clock only moves when a probe actually |
| 262 | +// blocks for the timeout it was given, so a case that claims the budget was |
| 263 | +// spent had to spend it. |
| 264 | +describe('toolchain probe budget', () => { |
| 265 | + // Failures are never memoized, but the recovery case below succeeds; each |
| 266 | + // case starts from an empty toolchain fingerprint cache so none of them |
| 267 | + // reads another's answer. |
| 268 | + beforeEach(resetAllProcessMemosForTests); |
| 269 | + |
| 270 | + test('a cold-start probe recovers on retry, and the stall it survived is charged to the budget', () => { |
| 271 | + const clock = installFakeToolchainClock(); |
| 272 | + const xcodebuildTimeouts: number[] = []; |
| 273 | + runCmdSync.mockImplementation((command: string, args: string[], options: ExecOptions) => { |
| 274 | + if (command !== 'xcodebuild') return appleToolchainProbeResult(command, args); |
| 275 | + xcodebuildTimeouts.push(options.timeoutMs ?? 0); |
| 276 | + if (xcodebuildTimeouts.length > 1) return appleToolchainProbeResult(command, args); |
| 277 | + throw blockForWholeTimeout(clock, command, args, options); |
| 278 | + }); |
| 279 | + runCmdSync.mockClear(); |
| 280 | + |
| 281 | + const metadata = resolveExpectedRunnerCacheMetadata(IOS_DEVICE); |
| 282 | + |
| 283 | + assert.equal(metadata.xcodeVersion, '26.2'); |
| 284 | + assert.equal(metadata.xcodeBuildVersion, '17C52'); |
| 285 | + // The retry runs on what the shared budget has left, not on a fresh |
| 286 | + // per-call ceiling: 45 s total minus the 30 s the first attempt burned. |
| 287 | + assert.deepEqual(xcodebuildTimeouts, [COLD_TOOLCHAIN_PROBE_TIMEOUT_MS, 15_000]); |
| 288 | + }); |
260 | 289 |
|
261 | | -test('a cold-start toolchain probe recovers on retry: the first call exceeds the budget, the second returns immediately', () => { |
262 | | - let xcodebuildAttempts = 0; |
263 | | - runCmdSync.mockImplementation((command: string, args: readonly string[]) => { |
264 | | - if (command === 'xcodebuild') { |
265 | | - xcodebuildAttempts += 1; |
266 | | - if (xcodebuildAttempts === 1) { |
267 | | - throw new AppError('COMMAND_FAILED', 'xcodebuild timed out after 30000ms', { |
268 | | - timeoutMs: 30_000, |
269 | | - }); |
270 | | - } |
271 | | - } |
272 | | - return appleToolchainProbeResult(command, args); |
| 290 | + test('a toolchain host that never returns stops at the shared budget instead of once per probe', () => { |
| 291 | + const clock = installFakeToolchainClock(); |
| 292 | + runCmdSync.mockImplementation((command: string, args: string[], options: ExecOptions) => { |
| 293 | + throw blockForWholeTimeout(clock, command, args, options); |
| 294 | + }); |
| 295 | + runCmdSync.mockClear(); |
| 296 | + |
| 297 | + assert.throws( |
| 298 | + () => resolveExpectedRunnerCacheMetadata(MACOS_DEVICE), |
| 299 | + (error: unknown) => { |
| 300 | + assert.ok(error instanceof AppError); |
| 301 | + assert.equal(error.code, 'COMMAND_FAILED'); |
| 302 | + assert.equal(error.details?.reason, 'apple_toolchain_probe_unavailable'); |
| 303 | + assert.equal(error.details?.retriable, true); |
| 304 | + expect(error.details?.hint).toContain('xcode-select -p'); |
| 305 | + expect(error.message).toContain('xcodebuild -version'); |
| 306 | + // The reported failure is the last attempt: 30 s, then the 15 s the |
| 307 | + // budget still had. |
| 308 | + expect(error.message).toContain('xcodebuild timed out after 15000ms'); |
| 309 | + expect(error.message).toContain('request budget'); |
| 310 | + return true; |
| 311 | + }, |
| 312 | + ); |
| 313 | + // 30 s + a 15 s retry spends the whole budget on the first probe; the two |
| 314 | + // xcrun probes then fail on the budget instead of blocking for 30 s each. |
| 315 | + assert.equal(runCmdSync.mock.calls.length, 2); |
| 316 | + assert.equal(clock.nowMs, 45_000); |
273 | 317 | }); |
274 | | - runCmdSync.mockClear(); |
275 | 318 |
|
276 | | - const metadata = resolveExpectedRunnerCacheMetadata(IOS_DEVICE); |
| 319 | + test('an owning request with 4 s left gets one 4 s attempt and no retry', () => { |
| 320 | + const clock = installFakeToolchainClock(); |
| 321 | + runCmdSync.mockImplementation((command: string, args: string[], options: ExecOptions) => { |
| 322 | + throw blockForWholeTimeout(clock, command, args, options); |
| 323 | + }); |
| 324 | + runCmdSync.mockClear(); |
| 325 | + |
| 326 | + assert.throws( |
| 327 | + () => resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR, undefined, { timeoutMs: 4_000 }), |
| 328 | + (error: unknown) => { |
| 329 | + assert.ok(error instanceof AppError); |
| 330 | + assert.equal(error.details?.reason, 'apple_toolchain_probe_unavailable'); |
| 331 | + expect(error.message).toContain('xcodebuild timed out after 4000ms'); |
| 332 | + expect(error.message).toContain('request budget'); |
| 333 | + return true; |
| 334 | + }, |
| 335 | + ); |
| 336 | + assert.equal(runCmdSync.mock.calls.length, 1); |
| 337 | + assert.equal(clock.nowMs, 4_000); |
| 338 | + }); |
277 | 339 |
|
278 | | - assert.equal(metadata.xcodeVersion, '26.2'); |
279 | | - assert.equal(metadata.xcodeBuildVersion, '17C52'); |
280 | | - expect(runCmdSync.mock.calls.filter(([command]) => command === 'xcodebuild')).toHaveLength(2); |
281 | | -}); |
| 340 | + test('a request canceled while a probe blocked surfaces the cancellation instead of retrying', () => { |
| 341 | + const clock = installFakeToolchainClock(); |
| 342 | + const request = new AbortController(); |
| 343 | + runCmdSync.mockImplementation((command: string, args: string[], options: ExecOptions) => { |
| 344 | + const timeout = blockForWholeTimeout(clock, command, args, options); |
| 345 | + request.abort(); |
| 346 | + throw timeout; |
| 347 | + }); |
| 348 | + runCmdSync.mockClear(); |
| 349 | + |
| 350 | + assert.throws( |
| 351 | + () => |
| 352 | + resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR, undefined, { signal: request.signal }), |
| 353 | + (error: unknown) => isRequestCanceledError(error), |
| 354 | + ); |
| 355 | + assert.equal(runCmdSync.mock.calls.length, 1); |
| 356 | + }); |
282 | 357 |
|
283 | | -test('a toolchain host that never returns still fails at the deadline with the same timeout error', () => { |
284 | | - runCmdSync.mockImplementation((command: string, args: readonly string[]) => { |
285 | | - if (command === 'xcodebuild') { |
286 | | - throw new AppError('COMMAND_FAILED', 'xcodebuild timed out after 30000ms', { |
287 | | - timeoutMs: 30_000, |
288 | | - }); |
289 | | - } |
290 | | - return appleToolchainProbeResult(command, args); |
| 358 | + test('an already-canceled request runs no toolchain probe at all', () => { |
| 359 | + installFakeToolchainClock(); |
| 360 | + runCmdSync.mockClear(); |
| 361 | + |
| 362 | + assert.throws( |
| 363 | + () => |
| 364 | + resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR, undefined, { |
| 365 | + signal: AbortSignal.abort(), |
| 366 | + }), |
| 367 | + (error: unknown) => isRequestCanceledError(error), |
| 368 | + ); |
| 369 | + assert.equal(runCmdSync.mock.calls.length, 0); |
291 | 370 | }); |
292 | | - runCmdSync.mockClear(); |
293 | 371 |
|
294 | | - try { |
295 | | - resolveExpectedRunnerCacheMetadata(MACOS_DEVICE); |
296 | | - assert.fail('expected an always-timing-out toolchain probe to fail the cache decision'); |
297 | | - } catch (error) { |
298 | | - assert.ok(error instanceof AppError); |
299 | | - assert.equal(error.code, 'COMMAND_FAILED'); |
300 | | - assert.equal(error.details?.retriable, true); |
301 | | - expect(error.message).toContain('xcodebuild -version'); |
302 | | - expect(error.message).toContain('xcodebuild timed out after 30000ms'); |
303 | | - } |
304 | | - // Exactly one retry, not an unbounded loop: the original attempt plus one retry. |
305 | | - expect(runCmdSync.mock.calls.filter(([command]) => command === 'xcodebuild')).toHaveLength(2); |
| 372 | + test('a probe that failed on its own and merely says "timed out" in its message is not retried', () => { |
| 373 | + installFakeToolchainClock(); |
| 374 | + runCmdSync.mockImplementation((command: string, args: string[]) => { |
| 375 | + if (command !== 'xcodebuild') return appleToolchainProbeResult(command, args); |
| 376 | + // No `timeoutMs` detail: this is the tool reporting its own failure, not |
| 377 | + // the exec layer killing it at a timeout we asked for. |
| 378 | + throw new AppError('COMMAND_FAILED', 'xcodebuild timed out after 10ms', { |
| 379 | + cmd: command, |
| 380 | + args, |
| 381 | + }); |
| 382 | + }); |
| 383 | + runCmdSync.mockClear(); |
| 384 | + |
| 385 | + assert.throws( |
| 386 | + () => resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR), |
| 387 | + (error: unknown) => { |
| 388 | + assert.ok(error instanceof AppError); |
| 389 | + expect(error.message).toContain('xcodebuild timed out after 10ms'); |
| 390 | + return true; |
| 391 | + }, |
| 392 | + ); |
| 393 | + expect(runCmdSync.mock.calls.filter(([command]) => command === 'xcodebuild')).toHaveLength(1); |
| 394 | + }); |
306 | 395 | }); |
307 | 396 |
|
| 397 | +/** |
| 398 | + * A clock the probes' own budget reads, advanced only by |
| 399 | + * {@link blockForWholeTimeout}. Without it a mock that throws immediately |
| 400 | + * proves nothing about a deadline: no time passes, so every budget looks |
| 401 | + * untouched however many attempts run. |
| 402 | + */ |
| 403 | +function installFakeToolchainClock(): { nowMs: number } { |
| 404 | + const clock = { nowMs: 0 }; |
| 405 | + appleRunnerTestHost.update({ |
| 406 | + deadlineFromTimeoutMs: (timeoutMs: number) => { |
| 407 | + const startedAtMs = clock.nowMs; |
| 408 | + const expiresAtMs = startedAtMs + Math.max(0, timeoutMs); |
| 409 | + return { |
| 410 | + remainingMs: () => Math.max(0, expiresAtMs - clock.nowMs), |
| 411 | + elapsedMs: () => Math.max(0, clock.nowMs - startedAtMs), |
| 412 | + isExpired: () => expiresAtMs - clock.nowMs <= 0, |
| 413 | + }; |
| 414 | + }, |
| 415 | + }); |
| 416 | + return clock; |
| 417 | +} |
| 418 | + |
| 419 | +/** A probe that blocked for its whole timeout and was then killed, as the exec layer reports it. */ |
| 420 | +function blockForWholeTimeout( |
| 421 | + clock: { nowMs: number }, |
| 422 | + command: string, |
| 423 | + args: string[], |
| 424 | + options: ExecOptions, |
| 425 | +): AppError { |
| 426 | + const timeoutMs = options.timeoutMs ?? 0; |
| 427 | + clock.nowMs += timeoutMs; |
| 428 | + return new AppError('COMMAND_FAILED', `${command} timed out after ${timeoutMs}ms`, { |
| 429 | + cmd: command, |
| 430 | + args, |
| 431 | + timeoutMs, |
| 432 | + }); |
| 433 | +} |
| 434 | + |
308 | 435 | test('a failing probe reports its exit status rather than a fabricated SDK version', () => { |
309 | 436 | runCmdSync.mockImplementation((command: string, args: readonly string[]) => |
310 | 437 | command === 'xcrun' |
|
0 commit comments