Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
159 commits
Select commit Hold shift + click to select a range
f309f9b
[vm-tools] Run guest startup scripts
gwdp Oct 23, 2026
d074bf7
[vm-tools] Use the shared warm Simulator terms in the changeset
gwdp Oct 26, 2026
76ccc7d
[vm-tools] Keep the startup process handle explicit
codex Oct 26, 2026
b473652
[vm-tools] Describe the VM startup script loader
codex Oct 26, 2026
e42fa74
[vm-tools] Refresh checks against the current base
codex Oct 26, 2026
50a78f3
[simulator-launcher] Prepare Simulator services at guest startup
gwdp Oct 23, 2026
93fc358
[simulator-launcher] Claim warm sessions once serve-sim is ready
gwdp Oct 24, 2026
1fad3cf
[simulator-launcher] Disable apsd during warmup
gwdp Oct 24, 2026
0cba53c
[simulator-launcher] Stop installing ffmpeg during warmup
gwdp Oct 24, 2026
f9ee3cb
[simulator-launcher] Start replacement services without waiting for s…
gwdp Oct 25, 2026
6c38a43
[simulator-launcher] Report the preinstalled Expo Go in the claim record
gwdp Oct 25, 2026
5c600e3
[simulator-launcher] Handle a failed serve-sim exit while waiting for…
gwdp Oct 25, 2026
64cddb0
[simulator-launcher] Read serve-sim readiness from its configured state
gwdp Oct 25, 2026
ee9ef41
[ci] Check the pull-request source for changesets
codex Oct 26, 2026
f8f672b
[simulator-launcher] Settle every old stop before failing a replacement
gwdp Oct 25, 2026
7bd9f73
[simulator-launcher] Treat an already shut down Simulator as cleaned up
gwdp Oct 25, 2026
8fef6f5
[simulator-launcher] Simplify warm Simulator cleanup
gwdp Oct 25, 2026
c9196e1
[simulator-launcher] Update the README for warm claim and cleanup
gwdp Oct 25, 2026
2fce2ff
[simulator-launcher] Simplify apsd disabling and the claim result
gwdp Oct 25, 2026
96e169d
[simulator-launcher] Start Simulator tools after serve-sim is ready
gwdp Oct 25, 2026
81a3bec
[simulator-launcher] Treat exited processes awaiting reap as stopped
gwdp Oct 25, 2026
026dc78
[simulator-launcher] Probe readiness only at the local serve-sim address
gwdp Oct 25, 2026
2854146
[simulator-launcher] Split the warm session into simulator and tool m…
gwdp Oct 25, 2026
3b0db0c
[simulator-launcher] Use the shared warm Simulator terms in the chang…
gwdp Oct 26, 2026
6b6ff97
[simulator-launcher] Use the shared warm Simulator terms in the README
gwdp Oct 26, 2026
2d01d9b
[simulator-launcher] Name the warm session handle type
codex Oct 26, 2026
5455ea0
[simulator-launcher] Use the package version instead of a copied bundle
codex Oct 26, 2026
e4ae4c4
[simulator-launcher] Accept a list of warm Simulator devices
codex Oct 26, 2026
b0e40d9
[simulator-launcher] Warm more devices after the default is ready
codex Oct 26, 2026
0779aed
[simulator-launcher] Claim either warm device and release the others
codex Oct 26, 2026
cca8819
[simulator-launcher] Prime the launcher cache before ready
codex Oct 26, 2026
dc6977b
[simulator-launcher] Keep unused-device cleanup from stopping the job
codex Oct 26, 2026
fe7c627
[simulator-launcher] Keep default warmup after an additional device f…
codex Oct 26, 2026
29fd654
[simulator-launcher] Keep the default device for blank configuration
codex Oct 26, 2026
28f4e83
[simulator-launcher] Bound cache priming and retry once
codex Oct 26, 2026
c1aad59
[simulator-launcher] Shut owned devices down in parallel at terminal …
codex Oct 26, 2026
bf44a35
[simulator-launcher] Finish default warmup before sharing Expo Go wit…
codex Oct 26, 2026
6646061
[simulator-launcher] Start warm serve-sim with explicit orphan ownership
codex Oct 26, 2026
395d51a
[worker] Manage warm Simulator services
gwdp Oct 23, 2026
3326bbd
[simulator-launcher] Start the selected Simulator before returning it…
codex Oct 26, 2026
6341fa7
[worker] Use the shared warm Simulator terms in the changeset
gwdp Oct 26, 2026
6e60a81
[simulator-launcher] Keep Expo Go failures scoped to their device
codex Oct 26, 2026
f6a81d1
[worker] Stop the recorded Simulator launcher version
codex Oct 26, 2026
86873db
[worker] Skip Simulator stop on images without a startup script
codex Oct 26, 2026
4feb3aa
[worker] Stop current-boot warmup when the script is absent
codex Oct 26, 2026
510a911
[worker] Skip launcher resolution after confirmed cleanup
codex Oct 26, 2026
2f23a9b
[worker] Recognize Bun cache misses alongside warning output
codex Oct 26, 2026
afe47a9
[worker] Honor the package manager for versioned launcher stop
codex Oct 26, 2026
f1f6ed6
[worker] Ignore invalid launcher state when no startup script exists
codex Oct 26, 2026
c71d91a
[stack] Integrate current main while preserving existing commits
codex Oct 27, 2026
68ec03a
[ci] Compare changes with the current base branch
codex Oct 27, 2026
1218140
[stack] Integrate current main while preserving existing commits
codex Oct 27, 2026
abf447d
[stack] Integrate current main while preserving existing commits
codex Oct 27, 2026
2d39f0f
[stack] Keep existing changes above Simulator prewarming
gwdp Oct 27, 2026
3d8868a
[simulator-launcher] Keep the base launcher focused on one device
gwdp Oct 27, 2026
163c8c9
[simulator-launcher] Preinstall Expo Go and warm additional Simulators
gwdp Oct 27, 2026
2aea4e4
[vm-tools] Read startup scripts from the local shared directory
gwdp Oct 27, 2026
7064c70
[stack] Keep existing changes above the launcher state cleanup
gwdp Oct 27, 2026
1f214df
[simulator-launcher] Keep one selected device in session state
gwdp Oct 27, 2026
5d5f78d
[simulator-launcher] Keep warmup ownership internal
gwdp Oct 27, 2026
c8d2281
[stack] Keep existing changes above explicit launcher lifetimes
gwdp Oct 27, 2026
2b471ce
[simulator-launcher] Make session startup and cleanup explicit
gwdp Oct 27, 2026
1de9a1d
[simulator-launcher] Keep extra warmup and device retirement owned
gwdp Oct 27, 2026
d2cba1c
[stack] Keep existing changes above launcher helpers
gwdp Oct 27, 2026
682a387
[stack] Keep launcher commands above service helpers
gwdp Oct 27, 2026
45c9499
[stack] Keep existing changes above launcher helpers
gwdp Oct 27, 2026
7f6a88c
[simulator-launcher] Add Simulator state and service helpers
gwdp Oct 27, 2026
30c4e47
[worker] Use existing package-manager helpers directly
gwdp Oct 27, 2026
29c2bd5
[stack] Keep existing changes above public launcher metadata
gwdp Oct 27, 2026
07a54f0
[stack] Keep existing changes above public launcher metadata
gwdp Oct 27, 2026
daafd74
[simulator-launcher] Keep the package public and versioned
gwdp Oct 27, 2026
9b4eb00
[stack] Keep existing changes above public launcher metadata
gwdp Oct 27, 2026
bae24fc
[stack] Retain the launcher foundation release entry
gwdp Oct 27, 2026
2122228
[stack] Retain the launcher foundation release entry
gwdp Oct 27, 2026
90ba211
[stack] Retain the launcher foundation release entry
gwdp Oct 27, 2026
fe455b7
[worker] Keep the warmup stop changeset scoped to worker
gwdp Oct 27, 2026
ada191c
[worker] Let the launcher handle Simulator stop state
gwdp Oct 27, 2026
91cc4f3
[worker] Stop warm Simulator services in the background
gwdp Oct 27, 2026
2cd52ab
[worker] Wait for warm cleanup before Simulator use
gwdp Oct 28, 2026
935aeb6
[simulator-launcher] Adopt the selected device on the warm server
gwdp Oct 28, 2026
c147c5e
[simulator-launcher] Share device preinstalls and reuse installed Exp…
gwdp Oct 28, 2026
f3ed3d2
[simulator-launcher] Verify readiness and drain intentional tool stops
gwdp Oct 28, 2026
b3b0cb4
[vm-tools] Run guest startup scripts
gwdp Oct 23, 2026
3aa2b25
[vm-tools] Use the shared warm Simulator terms in the changeset
gwdp Oct 26, 2026
cbd42cf
[vm-tools] Keep the startup process handle explicit
codex Oct 26, 2026
602b8b3
[vm-tools] Describe the VM startup script loader
codex Oct 26, 2026
64ef782
[vm-tools] Refresh checks against the current base
codex Oct 26, 2026
f5b60f3
[ci] Check the pull-request source for changesets
codex Oct 26, 2026
a549bce
[ci] Compare changes with the current base branch
codex Oct 27, 2026
23f653c
[vm-tools] Read startup scripts from the local shared directory
gwdp Oct 27, 2026
1e50360
[stack] Update #4721 onto current main
gwdp Oct 28, 2026
45c8a27
[stack] Update #4722 onto current main
gwdp Oct 28, 2026
ceafd4f
[stack] Update #4764 onto current main
gwdp Oct 28, 2026
3cb2178
[stack] Update #4766 onto current main
gwdp Oct 28, 2026
cb987fe
[worker] Fix warm stop job fixtures
gwdp Oct 28, 2026
f1edc83
[simulator-launcher] Add braces to the single-device selection guard
gwdp Oct 28, 2026
14bd55a
[stack] Include core launcher lint correction
gwdp Oct 28, 2026
6bfe01f
[stack] Include core launcher lint correction
gwdp Oct 28, 2026
eb02996
[stack] Restack after launcher changeset cleanup
gwdp Oct 28, 2026
0de9314
[stack] Restack after launcher changeset cleanup
gwdp Oct 28, 2026
b71a01f
[stack] Restack after launcher changeset cleanup
gwdp Oct 28, 2026
ffd7fc2
[simulator-launcher] Consolidate package changeset
gwdp Oct 28, 2026
753b8de
[simulator-launcher] Remove redundant launcher checks
gwdp Oct 28, 2026
9afd051
[simulator-launcher] Keep prewarming code in its layer
gwdp Oct 28, 2026
cadf6e9
[stack] Restack launcher cleanup
gwdp Oct 28, 2026
9dca86c
[simulator-launcher] Name process options and remove unused boot helper
gwdp Oct 28, 2026
dfcf515
[simulator-launcher] Merge the launcher changesets into one
gwdp Oct 28, 2026
7c8aaa5
[stack] Restack after merging the launcher changesets
gwdp Oct 28, 2026
561ac82
[simulator-launcher] Describe the shared serve-sim port in the change…
gwdp Oct 28, 2026
45aca05
[stack] Restack after merging the launcher changesets
gwdp Oct 28, 2026
400d108
[simulator-launcher] Keep the prewarming layer to the launcher
gwdp Oct 28, 2026
81cb88f
[stack] Restack after keeping the prewarming layer to the launcher
gwdp Oct 28, 2026
5a0cef3
[worker] Let build-tools wait on the launcher state for warm Simulato…
gwdp Oct 28, 2026
8f4b9b6
[vm-tools] Start at the fixed-group version
gwdp Oct 28, 2026
9d5b20c
[stack] Restack after the vm-tools version change
gwdp Oct 28, 2026
1f5e154
[simulator-launcher] Keep the launcher private until it has a CLI
gwdp Oct 28, 2026
1438cfc
[stack] Restack after the launcher release changes
gwdp Oct 28, 2026
8bd24a7
[stack] Restack after the launcher release changes
gwdp Oct 28, 2026
00d50f5
[stack] Restack after the launcher release changes
gwdp Oct 28, 2026
f001bcf
[simulator-launcher] Set up every warm device with one function
gwdp Oct 28, 2026
20978d4
[stack] Restack after the single warm device setup
gwdp Oct 28, 2026
794948a
[stack] Restack after shortening the launcher changeset
gwdp Oct 28, 2026
b47132b
[stack] Restack after shortening the launcher changeset
gwdp Oct 28, 2026
e86e181
[simulator-launcher] Shorten the launcher changeset
gwdp Oct 28, 2026
d82a6a6
[simulator-launcher] Set up all warm devices from one list
gwdp Oct 28, 2026
33b491d
[stack] Restack after setting up warm devices from one list
gwdp Oct 28, 2026
6e65ec2
[simulator-launcher] Wait for the default device once before the othe…
gwdp Oct 28, 2026
514d692
[stack] Restack after the simpler extra-device wait
gwdp Oct 28, 2026
4f75954
[simulator-launcher] Start device setup without waiting for it
gwdp Oct 28, 2026
4253f1a
[stack] Restack after starting device setup without waiting
gwdp Oct 28, 2026
4a714ba
[simulator-launcher] Set up all warm devices with one call
gwdp Oct 28, 2026
b5eba9b
[stack] Restack after setting up all warm devices with one call
gwdp Oct 28, 2026
75ae771
[simulator-launcher] Run apsd and the Expo Go preinstall in parallel …
gwdp Oct 28, 2026
d649245
[stack] Restack after the parallel device setup
gwdp Oct 28, 2026
b947807
[simulator-launcher] Check the claimed device and bound stop commands
gwdp Oct 28, 2026
638db05
[stack] Restack after the claim device check
gwdp Oct 28, 2026
c1bb572
[simulator-launcher] Keep the claimed Simulator when an unused tool s…
gwdp Oct 28, 2026
c29baa9
[stack] Restack after the tool stop fix
gwdp Oct 28, 2026
ad5b357
[simulator-launcher] Finish the default device apsd step before the c…
gwdp Oct 28, 2026
fffa313
[stack] Restack after the claim and tool stop fixes
gwdp Oct 28, 2026
21ef56e
[simulator-launcher] Attach the claim rejection checks before the cla…
gwdp Oct 28, 2026
5c57d8f
[stack] Restack after the claim test fix
gwdp Oct 28, 2026
00141eb
[stack] Restack after the claim test fix
gwdp Oct 28, 2026
8dc4bf9
[stack] Restack after the claim test fix
gwdp Oct 28, 2026
c33d766
[simulator-launcher] Prove the stop retries an unused tool
gwdp Oct 28, 2026
ea1fb5b
[stack] Restack after the retry test
gwdp Oct 28, 2026
0a27df2
[simulator-launcher] Say which claim waits for the apsd step
gwdp Oct 28, 2026
8611027
[stack] Restack after the retry test and README fix
gwdp Oct 28, 2026
be7e864
[simulator-launcher] Keep warmup running after a claim
gwdp Oct 28, 2026
bac4acf
[stack] Restack after keeping warmup running after a claim
gwdp Oct 28, 2026
a11b46d
[simulator-launcher] Return the claim without waiting for warmup
gwdp Oct 29, 2026
ccdbdad
[stack] Restack after the quick claim
gwdp Oct 29, 2026
a074e53
[simulator-launcher] Describe installs that adoption can stop
gwdp Oct 29, 2026
d7c9fd9
[stack] Restack after the launcher text fixes
gwdp Oct 29, 2026
f0e423d
[simulator-launcher] Remove non-null assertions
gwdp Oct 29, 2026
ca33299
[stack] Include launcher assertion cleanup
gwdp Oct 29, 2026
e03e30d
[simulator-launcher] Return the validated Argent service
gwdp Oct 29, 2026
dd033c0
[worker][build-tools] Wait for warm shutdown before device setup
gwdp Oct 29, 2026
7eb4abd
[build-tools] Require cleanup confirmation from the current boot
gwdp Oct 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
"@expo/downloader",
"@expo/turtle-spawn",
"@expo/template-file",
"create-eas-build-function"
"create-eas-build-function",
"@expo/vm-tools",
"@expo/simulator-launcher"
]
],
"linked": [],
Expand Down
6 changes: 6 additions & 0 deletions .changeset/prepared-simulator-worker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@expo/worker': patch
'@expo/build-tools': patch
---

Stop warmup in the background for jobs that do not use the warm Simulator, so job setup does not wait for it. Before build-tools boots or clones a Simulator, it waits until the launcher's state file records that cleanup finished, and fails if cleanup does not finish in time.
5 changes: 5 additions & 0 deletions .changeset/simulator-launcher-foundation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@expo/simulator-launcher': none
---

Add the state, request command and service helpers for the warm Simulator launcher. The package stays private until #4722 adds its CLI entry point.
5 changes: 5 additions & 0 deletions .changeset/simulator-launcher-prewarming.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@expo/simulator-launcher': minor
---

Accept a comma-separated list of warm Simulators in `EXPO_SIMULATOR_DEVICE`; the first is the default. One serve-sim serves every listed device on one port. After the default device is ready, warmup preinstalls the latest stable Expo Go from one download and tries to disable apsd on the other devices, until a job claims one. A claim can select any listed device and reports its preinstalled Expo Go.
5 changes: 5 additions & 0 deletions .changeset/simulator-launcher.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@expo/simulator-launcher': minor
---

Add `@expo/simulator-launcher`, which warms an iOS Simulator at VM boot and hands it to one EAS job (`start`, `claim`, `prepare-tools`, `stop`).
5 changes: 5 additions & 0 deletions .changeset/vm-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@expo/vm-tools': minor
---

Add a macOS startup script loader that runs `/usr/local/share/vm-tools/startup.sh` once per VM boot.
5 changes: 5 additions & 0 deletions packages/build-tools/src/utils/IosSimulatorUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { setTimeout } from 'node:timers/promises';
import { z } from 'zod';

import { retryAsync } from './retry';
import { waitForWarmSimulatorCleanupAsync } from './warmSimulatorCleanup';

export type IosSimulatorUuid = string & z.BRAND<'IosSimulatorUuid'>;
export type IosSimulatorName = string & z.BRAND<'IosSimulatorName'>;
Expand Down Expand Up @@ -119,6 +120,7 @@ export namespace IosSimulatorUtils {
destinationDeviceName: IosSimulatorName;
env: NodeJS.ProcessEnv;
}): Promise<void> {
await waitForWarmSimulatorCleanupAsync(env);
await spawn('xcrun', ['simctl', 'clone', sourceDeviceIdentifier, destinationDeviceName], {
env,
});
Expand All @@ -131,6 +133,7 @@ export namespace IosSimulatorUtils {
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
env: NodeJS.ProcessEnv;
}): Promise<void> {
await waitForWarmSimulatorCleanupAsync(env);
try {
const devices = await getAvailableDevicesAsync({ env, filter: 'available' });
const device = devices.find(
Expand Down Expand Up @@ -231,6 +234,7 @@ export namespace IosSimulatorUtils {
env: NodeJS.ProcessEnv;
launchdEnvironment?: Record<string, string>;
}): Promise<void> {
await waitForWarmSimulatorCleanupAsync(env);
Comment thread
gwdp marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Cold serve-sim depends on another rollout

The cold serve-sim host starts without the cleanup gate added to IosSimulatorUtils. The described separate serve-sim change must ship alongside this worker rollout to cover that path.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dependent cold-session layer, #4752, adds this gate in createColdIosSessionAsync before spawning serve-sim (coldIosSession.ts:31 at a1800b9). That layer must be included before deploying this worker rollout. Keeping this thread visible as a stack rollout prerequisite.

— Codex

const bootEnv = { ...env };
for (const [name, value] of Object.entries(launchdEnvironment)) {
bootEnv[`SIMCTL_CHILD_${name}`] = value;
Expand All @@ -253,6 +257,7 @@ export namespace IosSimulatorUtils {
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
env: NodeJS.ProcessEnv;
}): Promise<{ udid: IosSimulatorUuid }> {
await waitForWarmSimulatorCleanupAsync(env);
const bootstatusResult = await spawn(
'xcrun',
['simctl', 'bootstatus', deviceIdentifier, '-b'],
Expand Down
27 changes: 27 additions & 0 deletions packages/build-tools/src/utils/__tests__/IosSimulatorUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import path from 'node:path';

import { IosSimulatorUtils } from '../IosSimulatorUtils';
import { retryAsync } from '../retry';
import { waitForWarmSimulatorCleanupAsync } from '../warmSimulatorCleanup';

jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
Expand All @@ -14,13 +15,15 @@ jest.mock('@expo/turtle-spawn', () => ({
jest.mock('../retry', () => ({
retryAsync: jest.fn(async fn => await fn(0)),
}));
jest.mock('../warmSimulatorCleanup');

const mockedSpawn = jest.mocked(spawn);

describe('IosSimulatorUtils', () => {
beforeEach(() => {
mockedSpawn.mockReset();
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
jest.mocked(waitForWarmSimulatorCleanupAsync).mockReset().mockResolvedValue(undefined);
});

describe(IosSimulatorUtils.getAvailableRuntimesAsync, () => {
Expand Down Expand Up @@ -151,6 +154,30 @@ describe('IosSimulatorUtils', () => {
}) as any);
}

it('waits for warm cleanup before checking device state and writing accessibility prefs', async () => {
let finishCleanup: (() => void) | undefined;
jest.mocked(waitForWarmSimulatorCleanupAsync).mockImplementationOnce(
async () =>
await new Promise<void>(resolve => {
finishCleanup = resolve;
})
);
mockAvailableDevices([device()]);

const setup = IosSimulatorUtils.enableAccessibilitySettingsAsync({
deviceIdentifier: device().udid as any,
env: {},
});
expect(mockedSpawn).not.toHaveBeenCalled();
expect(finishCleanup).toBeDefined();
finishCleanup?.();
await setup;

expect(mockedSpawn).toHaveBeenCalledWith('plutil', expect.anything(), {
env: {},
});
});

it('writes accessibility prefs for an exact shutdown simulator match', async () => {
mockAvailableDevices([
device({
Expand Down
125 changes: 125 additions & 0 deletions packages/build-tools/src/utils/__tests__/warmSimulatorCleanup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs/promises';

import { waitForWarmSimulatorCleanupAsync } from '../warmSimulatorCleanup';

const STATE_FILE = '/tmp/expo-simulator-startup.json';
const STARTUP_SCRIPT = '/usr/local/share/vm-tools/startup.sh';
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
jest.mock('@expo/turtle-spawn');

async function writeStateAsync(state: object): Promise<void> {
await fs.mkdir('/tmp', { recursive: true });
await fs.writeFile(STATE_FILE, JSON.stringify({ bootUuid: 'current-boot', ...state }));
}

beforeEach(() => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
jest.mocked(spawn).mockResolvedValue({ stdout: 'current-boot\n' } as never);
});
afterEach(() => {
Object.defineProperty(process, 'platform', platform);
});

it('continues at once on a VM without warmup', async () => {
await expect(waitForWarmSimulatorCleanupAsync({})).resolves.toBeUndefined();
expect(spawn).not.toHaveBeenCalled();
});

it('ignores a previous boot cleanup record and reads the current boot only once', async () => {
await writeStateAsync({ status: 'disabled', bootUuid: 'old-boot' });
let finished = false;
const waiting = waitForWarmSimulatorCleanupAsync({}, { pollMs: 5 }).then(() => {
finished = true;
});
await new Promise(resolve => setTimeout(resolve, 30));
expect(finished).toBe(false);
await writeStateAsync({ status: 'disabled' });
await waiting;
expect(spawn).toHaveBeenCalledTimes(1);
});

it('does not accept a cleanup record without a boot ID', async () => {
await writeStateAsync({ status: 'disabled', bootUuid: undefined });
await expect(waitForWarmSimulatorCleanupAsync({}, { timeoutMs: 20, pollMs: 5 })).rejects.toThrow(
'Warm Simulator cleanup did not finish'
);
});

it.each(['failed', 'empty'])('fails closed when the current boot ID is %s', async mode => {
await writeStateAsync({ status: 'disabled' });
if (mode === 'failed') {
jest.mocked(spawn).mockRejectedValueOnce(new Error('sysctl failed'));
} else {
jest.mocked(spawn).mockResolvedValueOnce({ stdout: '' } as never);
}
await expect(waitForWarmSimulatorCleanupAsync({})).rejects.toThrow();
});

it('waits for cleanup when warmup is installed but has not written state yet', async () => {
await fs.mkdir('/usr/local/share/vm-tools', { recursive: true });
await fs.writeFile(STARTUP_SCRIPT, '');
let finished = false;
const waiting = waitForWarmSimulatorCleanupAsync({}, { pollMs: 5 }).then(() => {
finished = true;
});
await new Promise(resolve => setTimeout(resolve, 30));
expect(finished).toBe(false);
await writeStateAsync({ status: 'starting' });
await new Promise(resolve => setTimeout(resolve, 30));
expect(finished).toBe(false);
await writeStateAsync({ status: 'disabled' });
await waiting;
});

it('times out when installed warmup never writes state', async () => {
await fs.mkdir('/usr/local/share/vm-tools', { recursive: true });
await fs.writeFile(STARTUP_SCRIPT, '');
await expect(waitForWarmSimulatorCleanupAsync({}, { timeoutMs: 20, pollMs: 5 })).rejects.toThrow(
'Warm Simulator cleanup did not finish; the Simulator cannot be used.'
);
});

it('waits until the launcher records that cleanup finished', async () => {
await writeStateAsync({ status: 'ready' });
let finished = false;
const waiting = waitForWarmSimulatorCleanupAsync({}, { pollMs: 5 }).then(() => {
finished = true;
});
await new Promise(resolve => setTimeout(resolve, 30));
expect(finished).toBe(false);
await writeStateAsync({ status: 'stopping' });
await new Promise(resolve => setTimeout(resolve, 30));
expect(finished).toBe(false);
await writeStateAsync({ status: 'disabled' });
await waiting;
expect(finished).toBe(true);
});

it('fails with the recorded error when cleanup does not finish in time', async () => {
await writeStateAsync({ status: 'stopping', error: 'serve-sim did not exit' });
await expect(waitForWarmSimulatorCleanupAsync({}, { timeoutMs: 20, pollMs: 5 })).rejects.toThrow(
'Warm Simulator cleanup did not finish (serve-sim did not exit); the Simulator cannot be used.'
);
});

it('stops waiting when the job is cancelled', async () => {
await writeStateAsync({ status: 'stopping' });
const controller = new AbortController();
const waiting = waitForWarmSimulatorCleanupAsync({}, { pollMs: 5, signal: controller.signal });
controller.abort(new Error('cancelled'));
await expect(waiting).rejects.toThrow('cancelled');
});

it('does not wait for a job that uses the warm Simulator', async () => {
await writeStateAsync({ status: 'claimed' });
await expect(
waitForWarmSimulatorCleanupAsync({ EAS_USE_WARM_SIMULATOR: '1' }, { timeoutMs: 0 })
).resolves.toBeUndefined();
});

it('does nothing outside macOS', async () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
await writeStateAsync({ status: 'ready' });
await expect(waitForWarmSimulatorCleanupAsync({}, { timeoutMs: 0 })).resolves.toBeUndefined();
});
61 changes: 61 additions & 0 deletions packages/build-tools/src/utils/warmSimulatorCleanup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { SystemError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { existsSync } from 'node:fs';
import fs from 'node:fs/promises';
import { setTimeout as sleepAsync } from 'node:timers/promises';

const LAUNCHER_STATE_FILE = '/tmp/expo-simulator-startup.json';
const STARTUP_SCRIPT = '/usr/local/share/vm-tools/startup.sh';

/**
* The worker stops warmup in the background for a job that does not use the warm Simulator. Wait
* here, before the job changes a Simulator, until the launcher records that its cleanup finished.
*/
export async function waitForWarmSimulatorCleanupAsync(
env: NodeJS.ProcessEnv,
{
timeoutMs = 150_000,
pollMs = 250,
signal,
}: { timeoutMs?: number; pollMs?: number; signal?: AbortSignal } = {}
): Promise<void> {
if (process.platform !== 'darwin' || env.EAS_USE_WARM_SIMULATOR === '1') {
return;
}
Comment thread
gwdp marked this conversation as resolved.
const deadline = Date.now() + timeoutMs;
let bootUuid: string | undefined;
for (;;) {
signal?.throwIfAborted();
let state: { status?: unknown; error?: unknown; bootUuid?: unknown };
try {
state = JSON.parse(await fs.readFile(LAUNCHER_STATE_FILE, 'utf8'));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT' && !existsSync(STARTUP_SCRIPT)) {
return;
}
// Warmup may still be loading; only disabled confirms cleanup on an enabled VM.
state = {};
}
if (state.status === 'disabled' && typeof state.bootUuid === 'string') {
bootUuid ??= (
await spawn('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid'], {
signal,
timeout: Math.max(1, deadline - Date.now()),
})
).stdout.trim();
if (!bootUuid) {
throw new SystemError('Could not read the current VM boot ID.');
}
if (state.bootUuid === bootUuid) {
return;
}
}
if (Date.now() >= deadline) {
throw new SystemError(
`Warm Simulator cleanup did not finish${typeof state.error === 'string' ? ` (${state.error})` : ''}; the Simulator cannot be used.`
);
}
// The signal is checked at each poll, so a cancelled job stops within one interval.
await sleepAsync(pollMs);
}
}
Loading
Loading