also looking at this
fix(native): preserve workspace sources and native overlay lifecycle
#384
20 files
+500
-227
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -90,6 +90,7 @@ const device = await createAndroidEmulator({ | ||
| 90 | 90 | avdmanager: '/ci/pantry/.bin/avdmanager', |
| 91 | 91 | image: 'system-images;android-35;google_apis_playstore;x86_64', |
| 92 | 92 | acceleration: 'software', |
| 93 | memoryMb: 4096, | |
| 93 | 94 | artifactsDirectory: '/ci/artifacts/android', |
| 94 | 95 | acceptLicenses: true, |
| 95 | 96 | }) |
| @@ -106,6 +107,11 @@ the Android emulator's explicit `-accel off` mode on x86_64 hosts; it provides | ||
| 106 | 107 | functional test evidence, with no claim about hardware performance. The public |
| 107 | 108 | helper defaults to a ten-minute boot budget, and hardware mode cannot exceed |
| 108 | 109 | it. Software callers may explicitly set `bootTimeoutMs` up to twenty minutes. |
| 110 | `memoryMb` selects the requested guest RAM, defaulting to 2048 MB. It accepts | |
| 111 | the emulator's documented [1536–8192 MB range](https://developer.android.com/studio/run/emulator-commandline#startup-options). | |
| 112 | The stock Play software verifier requests 4096 MB after retained cold boots | |
| 113 | showed low-memory/process-death churn and a SystemUI ANR. It keeps the same | |
| 114 | image, two virtual CPUs, readiness checks and twenty-minute deadline. | |
| 109 | 115 | The stock Play software CI verifier uses that cold-provisioning profile: the |
| 110 | 116 | API 35 image took 943,520 ms in the retained diagnostic run |
| 111 | 117 | [39113277828](https://github.com/craft-native/craft/actions/runs/39113277828). |
| @@ -14,6 +14,8 @@ export interface AndroidEmulatorOptions { | ||
| 14 | 14 | acceptLicenses?: boolean |
| 15 | 15 | /** Defaults to 10 minutes; hardware is capped there, software cold provisioning at 20 minutes. */ |
| 16 | 16 | bootTimeoutMs?: number |
| 17 | /** Requested guest RAM, 1536–8192 MB (Android emulator limits); defaults to 2048. */ | |
| 18 | memoryMb?: number | |
| 17 | 19 | env?: Record<string, string | undefined> |
| 18 | 20 | } |
| 19 | 21 | |
| @@ -40,6 +42,9 @@ export async function createAndroidEmulator(options: AndroidEmulatorOptions): Pr | ||
| 40 | 42 | const maximumBootTimeoutMs = options.acceleration === 'software' ? 1_200_000 : 600_000 |
| 41 | 43 | if (!Number.isSafeInteger(bootTimeoutMs) || bootTimeoutMs < 1000 || bootTimeoutMs > maximumBootTimeoutMs) |
| 42 | 44 | throw new Error(`Android bootTimeoutMs must be an integer between 1000 and ${maximumBootTimeoutMs} milliseconds`) |
| 45 | const memoryMb = options.memoryMb ?? 2048 | |
| 46 | if (!Number.isSafeInteger(memoryMb) || memoryMb < 1536 || memoryMb > 8192) | |
| 47 | throw new Error('Android memoryMb must be an integer between 1536 and 8192 megabytes') | |
| 43 | 48 | const port = options.port ?? 5558 |
| 44 | 49 | if (!Number.isInteger(port) || port < 5554 || port > 5682 || port % 2 !== 0) throw new Error('Android console ports must be even and between 5554 and 5682') |
| 45 | 50 | const sdk = resolve(options.sdkRoot) |
| @@ -128,12 +133,12 @@ export async function createAndroidEmulator(options: AndroidEmulatorOptions): Pr | ||
| 128 | 133 | const hostCpus = cpus() |
| 129 | 134 | cpuStart = hostCpus.reduce((total, cpu) => total + Object.values(cpu.times).reduce((sum, value) => sum + value, 0), 0) |
| 130 | 135 | idleStart = hostCpus.reduce((total, cpu) => total + cpu.times.idle, 0) |
| 131 | child = Bun.spawn([executable, '-avd', name, '-port', String(port), '-no-window', '-no-snapshot', '-no-audio', '-no-boot-anim', '-gpu', 'software', '-memory', '2048', '-cores', '2', '-accel', options.acceleration === 'software' ? 'off' : 'on'], { env, stdout: log, stderr: log }) | |
| 136 | child = Bun.spawn([executable, '-avd', name, '-port', String(port), '-no-window', '-no-snapshot', '-no-audio', '-no-boot-anim', '-gpu', 'software', '-memory', String(memoryMb), '-cores', '2', '-accel', options.acceleration === 'software' ? 'off' : 'on'], { env, stdout: log, stderr: log }) | |
| 132 | 137 | identity = { |
| 133 | 138 | image: options.image, acceleration: options.acceleration, accelerationProbeExitCode: acceleration.code, |
| 134 | 139 | bootTimeoutMs, bootStartedAt: new Date(bootStartedAt).toISOString(), |
| 135 | 140 | host: { cpuModel: hostCpus[0]?.model, logicalCpus: hostCpus.length, availableParallelism: availableParallelism(), memoryBytes: totalmem() }, |
| 136 | virtualCpus: 2, memoryMb: 2048, serial, avdDirectory: env.ANDROID_AVD_HOME, emulatorPid: child.pid, | |
| 141 | virtualCpus: 2, memoryMb, serial, avdDirectory: env.ANDROID_AVD_HOME, emulatorPid: child.pid, | |
| 137 | 142 | } |
| 138 | 143 | await Bun.write(join(artifacts, 'android-emulator.json'), JSON.stringify(identity, null, 2) + '\n') |
| 139 | 144 | const deadline = bootStartedAt + bootTimeoutMs |
| @@ -137,6 +137,19 @@ test('an offline guest that never connected still fails its deadline and is disp | ||
| 137 | 137 | finally { rmSync(root, { recursive: true, force: true }) } |
| 138 | 138 | }, 10_000) |
| 139 | 139 | |
| 140 | test('explicit guest memory reaches the emulator process and its retained identity', async () => { | |
| 141 | const { root, options } = fixture(true) | |
| 142 | let session: Awaited<ReturnType<typeof createAndroidEmulator>> | undefined | |
| 143 | try { | |
| 144 | session = await createAndroidEmulator({ ...options, memoryMb: 4096 }) | |
| 145 | expect(readFileSync(join(options.sdkRoot, 'calls'), 'utf8')).toContain('-memory 4096') | |
| 146 | const identity = JSON.parse(readFileSync(join(options.artifactsDirectory, 'android-emulator.json'), 'utf8')) | |
| 147 | expect(identity.memoryMb).toBe(4096) | |
| 148 | expect(identity.virtualCpus).toBe(2) | |
| 149 | } | |
| 150 | finally { await session?.dispose(); rmSync(root, { recursive: true, force: true }) } | |
| 151 | }, 10_000) | |
| 152 | ||
| 140 | 153 | test('invalid boot budgets fail before starting tools or creating owned device data', async () => { |
| 141 | 154 | const { root, options } = fixture(true) |
| 142 | 155 | try { |
| @@ -153,6 +166,18 @@ test('invalid boot budgets fail before starting tools or creating owned device d | ||
| 153 | 166 | finally { rmSync(root, { recursive: true, force: true }) } |
| 154 | 167 | }) |
| 155 | 168 | |
| 169 | test('invalid guest memory fails before starting tools or creating owned device data', async () => { | |
| 170 | const { root, options } = fixture(true) | |
| 171 | try { | |
| 172 | for (const memoryMb of [NaN, Infinity, -Infinity, 0, 1535, 8193, 4096.5]) { | |
| 173 | await expect(createAndroidEmulator({ ...options, memoryMb })).rejects.toThrow('memoryMb') | |
| 174 | expect(existsSync(join(options.sdkRoot, 'calls'))).toBe(false) | |
| 175 | expect(existsSync(options.temporaryDirectory!)).toBe(false) | |
| 176 | } | |
| 177 | } | |
| 178 | finally { rmSync(root, { recursive: true, force: true }) } | |
| 179 | }) | |
| 180 | ||
| 156 | 181 | test('boot completion alone does not admit a device before SettingsProvider is usable', async () => { |
| 157 | 182 | const { root, options } = fixture(true, true) |
| 158 | 183 | let session: Awaited<ReturnType<typeof createAndroidEmulator>> | undefined |
| @@ -266,6 +291,7 @@ test('the SDK process fixture records hardware mode and reaps only its owned pro | ||
| 266 | 291 | session = await createAndroidEmulator(options) |
| 267 | 292 | const identity = JSON.parse(readFileSync(join(options.artifactsDirectory, 'android-emulator.json'), 'utf8')) |
| 268 | 293 | expect(identity.acceleration).toBe('hardware') |
| 294 | expect(identity.memoryMb).toBe(2048) | |
| 269 | 295 | expect(identity.bootedAt).toBeString() |
| 270 | 296 | expect(identity.bootTimeoutMs).toBe(42_000) |
| 271 | 297 | expect(identity.bootElapsedMs).toBeGreaterThanOrEqual(0) |
| @@ -14,6 +14,10 @@ const diagnostic = args.length === 1 | ||
| 14 | 14 | // measured API 35 image took 943520 ms; this explicit software-only profile |
| 15 | 15 | // leaves the public helper default and hardware acceptance at 600000 ms. |
| 16 | 16 | const bootTimeoutMs = 1_200_000 |
| 17 | // Both retained 2 GB-profile failures show low-memory/process-death churn and | |
| 18 | // a SystemUI ANR. Give the unmodified stock image an explicit 4 GB guest; | |
| 19 | // readiness, software acceleration, CPU count and boot deadline stay intact. | |
| 20 | const memoryMb = 4096 | |
| 17 | 21 | const expectedFingerprint = diagnostic ? process.env.CRAFT_ANDROID_DIAGNOSTIC_FINGERPRINT : undefined |
| 18 | 22 | if (diagnostic && !expectedFingerprint) |
| 19 | 23 | throw new Error('The cold-provisioning diagnostic requires the failed stock image fingerprint') |
| @@ -35,7 +39,7 @@ let session: Awaited<ReturnType<typeof createAndroidEmulator>> | undefined | ||
| 35 | 39 | const proof: Record<string, unknown> = { |
| 36 | 40 | purpose: diagnostic ? 'software-cold-provisioning-diagnostic-not-release-acceptance' : 'software-emulator-acceptance', |
| 37 | 41 | platform: process.platform, architecture: process.arch, acceleration: 'software', sdkmanager, avdmanager, javaHome, |
| 38 | provisioningProfile: 'stock-play-software-cold', bootTimeoutMs, | |
| 42 | provisioningProfile: 'stock-play-software-cold', bootTimeoutMs, memoryMb, | |
| 39 | 43 | expectedFingerprint, runnerImage: process.env.ImageOS, runnerImageVersion: process.env.ImageVersion, |
| 40 | 44 | } |
| 41 | 45 | |
| @@ -44,7 +48,7 @@ try { | ||
| 44 | 48 | sdkRoot: join(owned, 'sdk'), sdkmanager, avdmanager, |
| 45 | 49 | image: 'system-images;android-35;google_apis_playstore;x86_64', |
| 46 | 50 | acceleration: 'software', artifactsDirectory: artifacts, |
| 47 | bootTimeoutMs, | |
| 51 | bootTimeoutMs, memoryMb, | |
| 48 | 52 | temporaryDirectory: owned, acceptLicenses: true, |
| 49 | 53 | env: { JAVA_HOME: javaHome }, |
| 50 | 54 | }) |
| @@ -63,11 +67,15 @@ try { | ||
| 63 | 67 | const abi = await adb(['shell', 'getprop', 'ro.product.cpu.abi']) |
| 64 | 68 | const playStore = await adb(['shell', 'pm', 'path', 'com.android.vending']) |
| 65 | 69 | const fingerprint = await adb(['shell', 'getprop', 'ro.build.fingerprint']) |
| 70 | const meminfo = await adb(['shell', 'cat', '/proc/meminfo']) | |
| 71 | const guestMemTotalKb = Number(/^MemTotal:\s+(\d+)\s+kB$/m.exec(meminfo)?.[1]) | |
| 66 | 72 | if (api !== '35' || abi !== 'x86_64' || !/^package:\/\S+/.test(playStore) || !fingerprint) |
| 67 | 73 | throw new Error('The owned emulator must run the requested stock Play image') |
| 74 | if (!Number.isSafeInteger(guestMemTotalKb) || guestMemTotalKb <= 0) | |
| 75 | throw new Error('The owned emulator must report its actual guest memory') | |
| 68 | 76 | if (diagnostic && fingerprint !== expectedFingerprint) |
| 69 | 77 | throw new Error('The stock Play image changed; this diagnostic cannot explain the retained cold-boot failures') |
| 70 | Object.assign(proof, { serial: session.serial, api, abi, playStore, fingerprint, verifiedAt: new Date().toISOString() }) | |
| 78 | Object.assign(proof, { serial: session.serial, api, abi, playStore, fingerprint, guestMemTotalKb, verifiedAt: new Date().toISOString() }) | |
| 71 | 79 | } |
| 72 | 80 | finally { |
| 73 | 81 | try { |