Cache mobile typechecks and overlap CI setup (#28192)

* ci: run mobile runtime test groups on separate runners

* ci: use a lightweight runner for the mobile result gate

* ci: distribute Bun tests across both mobile runners

* ci: reuse mobile compiler state without adding runner queues

* test: verify eviction of source variants in one cache bucket

* test: baseline existing serializer limits in the capacity capture
This commit is contained in:
Neil
2026-10-13 03:35:51 -07:00
committed by GitHub
parent 2cbb71963c
commit fba7b753ec
13 changed files with 224 additions and 24 deletions
+2
View File
@@ -16,3 +16,5 @@ dist/
*.mobileprovision
*.orig.*
web-build/
.cache/typechecks/
@@ -0,0 +1,47 @@
import { existsSync, mkdtempSync, realpathSync, writeFileSync, unlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it, vi } from 'vitest'
import { removeTreeSync } from '../../src/shared/windows-transient-lock-removal'
import { collectTypecheckPass, findTsNocheckFiles } from './check-tests-typecheck-ratchet.mjs'
it('rechecks changed types, diagnostics and test membership with incremental state', () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-mobile-typecheck-')))
const cache = join(root, '.cache')
vi.stubEnv('ORCA_MOBILE_TYPECHECK_CACHE_DIR', cache)
try {
writeFileSync(
join(root, 'tsconfig.test.json'),
JSON.stringify({
compilerOptions: { strict: true, skipLibCheck: true, types: [], target: 'ES2022' },
include: ['*.ts']
})
)
writeFileSync(join(root, 'model.ts'), 'export interface Shape { value: number }')
const test = join(root, 'value.test.ts')
const source = "import type { Shape } from './model'; const shape: Shape = { value: 1 }"
writeFileSync(test, source)
const clean = collectTypecheckPass(root)
expect(clean).toEqual({ failing: [], programTestFiles: ['value.test.ts'] })
expect(existsSync(join(cache, 'tests.tsbuildinfo'))).toBe(true)
expect(collectTypecheckPass(root)).toEqual(clean)
writeFileSync(join(root, 'model.ts'), 'export interface Shape { value: string }')
const failed = collectTypecheckPass(root)
expect(failed.failing).toEqual(['value.test.ts'])
expect(collectTypecheckPass(root)).toEqual(failed)
writeFileSync(join(root, 'new.test.ts'), 'export const value: number = 1')
expect(collectTypecheckPass(root).programTestFiles).toEqual(['new.test.ts', 'value.test.ts'])
writeFileSync(test, `// @ts-nocheck\n${source}`)
const nocheck = collectTypecheckPass(root)
expect(nocheck.failing).toEqual([])
expect(findTsNocheckFiles(root, nocheck.programTestFiles)).toEqual(['value.test.ts'])
unlinkSync(test)
expect(collectTypecheckPass(root)).toEqual({ failing: [], programTestFiles: ['new.test.ts'] })
} finally {
vi.unstubAllEnvs()
removeTreeSync(root)
}
}, 15_000)
@@ -1,4 +1,4 @@
import { spawnSync } from 'node:child_process'
import { runProcessSync } from '@orca/process-host'
import fs from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
@@ -148,22 +148,26 @@ export function diffBaseline(current, baseline) {
// script: on Windows the shim is tsc.CMD and an extensionless path gets .exe appended.
function runTsc(root, args) {
const entry = createRequire(import.meta.url).resolve('typescript/lib/tsc.js')
const result = spawnSync(process.execPath, [entry, ...args], {
return runProcessSync({
program: process.execPath,
args: [entry, ...args],
cwd: root,
encoding: 'utf8',
shell: false,
maxBuffer: 64 * 1024 * 1024
timeoutMs: null,
maxOutputBytes: 64 * 1024 * 1024
})
if (result.error) {
throw result.error
}
return result
}
// tsc exits non-zero on type errors, which is the expected state here, so only a crash is fatal.
// One pass answers both questions: --listFiles names the program, the diagnostics name the failures.
export function collectTypecheckPass(root = process.cwd()) {
const result = runTsc(root, ['--noEmit', '--listFiles', '-p', PROJECT])
const cache = process.env.ORCA_MOBILE_TYPECHECK_CACHE_DIR
const result = runTsc(root, [
'--noEmit',
'--listFiles',
'-p',
PROJECT,
...(cache ? ['--incremental', '--tsBuildInfoFile', path.join(cache, 'tests.tsbuildinfo')] : [])
])
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`
return {
failing: parseFailingFiles(output),
@@ -13,6 +13,7 @@ describe('compiled operation code', () => {
original
)
expect(compiledOperationModule('different.ts', source, '')).not.toBe(original)
expect(compiledOperationModule('original.ts', source, '')).toBe(original)
})
it('bounds retained code and still evaluates an evicted module correctly', () => {
@@ -27,4 +28,17 @@ describe('compiled operation code', () => {
recompiled(() => undefined, exports)
expect(exports).toEqual({ value: 42 })
})
it('bounds retained variants of one file, not only the number of filenames', () => {
const source = 'export const value = 42'
const original = compiledOperationModule('variants.ts', source, '')
for (let index = 0; index < 512; index++) {
compiledOperationModule('variants.ts', `${source}; export const variant = ${index}`, '')
}
const recompiled = compiledOperationModule('variants.ts', source, '')
expect(recompiled).not.toBe(original)
const exports = {}
recompiled(() => undefined, exports)
expect(exports).toEqual({ value: 42 })
})
})
@@ -1,15 +1,21 @@
import { compileFunction } from 'node:vm'
import ts from 'typescript'
const compiled = new Map<string, ReturnType<typeof compileFunction>>()
type CompiledModule = {
source: string
evaluate: ReturnType<typeof compileFunction>
}
const compiled = new Map<string, Map<string, CompiledModule>>()
const insertionOrder = new Map<CompiledModule, string>()
const MAX_COMPILED_MODULES = 512
/** Only code is shared: each invocation receives a fresh require closure and exports object. */
export function compiledOperationModule(file: string, source: string, exposure: string) {
const key = JSON.stringify([file, source, exposure])
const cached = compiled.get(key)
const key = JSON.stringify([file, exposure])
const cached = compiled.get(key)?.get(source)
if (cached) {
return cached
return cached.evaluate
}
const output = ts.transpileModule(source, {
compilerOptions: {
@@ -20,12 +26,22 @@ export function compiledOperationModule(file: string, source: string, exposure:
}
}).outputText
const evaluate = compileFunction(output + exposure, ['require', 'exports'], { filename: file })
if (compiled.size >= MAX_COMPILED_MODULES) {
const oldest = compiled.keys().next().value
if (insertionOrder.size >= MAX_COMPILED_MODULES) {
const oldest = insertionOrder.entries().next().value
if (oldest !== undefined) {
compiled.delete(oldest)
const [entry, variantKey] = oldest
insertionOrder.delete(entry)
const entries = compiled.get(variantKey)
entries?.delete(entry.source)
if (!entries?.size) {
compiled.delete(variantKey)
}
}
}
compiled.set(key, evaluate)
const entry = { source, evaluate }
const entries = compiled.get(key) ?? new Map<string, CompiledModule>()
entries.set(source, entry)
compiled.set(key, entries)
insertionOrder.set(entry, key)
return evaluate
}
@@ -0,0 +1,30 @@
import { expect, it, vi } from 'vitest'
import { internRecording, resolveRecording } from './golden-value-pool'
it('serializes each pooled value once for its hash and collision check', () => {
const recording = {
scenario: 'serialization',
checkpoints: [
{
id: 'one',
observation: {
sender: [],
payloads: [],
settlements: {},
state: { value: 'one' },
effects: []
}
}
]
}
const stringify = vi.spyOn(JSON, 'stringify')
try {
const interned = internRecording(recording)
const calls = stringify.mock.calls.length
stringify.mockRestore()
expect(calls).toBe(2)
expect(resolveRecording(interned.values, interned.recording)).toEqual(recording)
} finally {
stringify.mockRestore()
}
})
@@ -61,7 +61,11 @@ export function canonicalJson(value: RecordedValue): string {
}
export function valueHash(value: RecordedValue): string {
return createHash('sha256').update(canonicalJson(value)).digest('hex').slice(0, 12)
return canonicalJsonHash(canonicalJson(value))
}
function canonicalJsonHash(json: string): string {
return createHash('sha256').update(json).digest('hex').slice(0, 12)
}
function listEntries(at: string, value: RecordedValue): RecordedValue[] {
@@ -86,8 +90,8 @@ export function internRecording(recording: Recording): {
const pool: ValuePool = {}
const canonical = new Map<string, string>()
const intern = (entry: RecordedValue, at: string): string => {
const hash = valueHash(entry)
const json = canonicalJson(entry)
const hash = canonicalJsonHash(json)
const seen = canonical.get(hash)
if (seen !== undefined && seen !== json) {
throw new Error(`Golden value hash collision at ${hash} (${at})`)