Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/argent-stop-during-startup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---

Stop an Argent session that is still starting up when it is stopped.
22 changes: 22 additions & 0 deletions packages/build-tools/src/__tests__/utils/startupWatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { type watchDeviceRunSessionStartup } from '../../steps/utils/deviceRunSessionStartup';
import { DeviceRunSessionStoppedError } from '../../steps/utils/remoteDeviceRunSession';

/**
* A `watchDeviceRunSessionStartup` stand-in that never polls. A test calls `stop` on the
* returned watch (`jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value`) to act like
* a Stop request during startup.
*/
export function createStartupWatchMock({
startup,
}: Parameters<typeof watchDeviceRunSessionStartup>[0]): ReturnType<
typeof watchDeviceRunSessionStartup
> & { stop: () => void } {
const stopped = new DeviceRunSessionStoppedError('Stopped during startup.');
return {
stopped,
stop: () => startup.abort(stopped),
isStopped: () => startup.signal.reason === stopped,
completeAsync: jest.fn(async () => startup.signal.throwIfAborted()),
finishAsync: jest.fn(async () => {}),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import { watchDeviceRunSessionStartup } from '../../utils/deviceRunSessionStartup';
import { createStartupWatchMock } from '../../../__tests__/utils/startupWatch';
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact';

Expand Down Expand Up @@ -80,6 +82,9 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
jest.mock('../../utils/deviceRunSessionStartup', () => ({
watchDeviceRunSessionStartup: jest.fn(),
}));

const TEST_HOME = path.join(os.tmpdir(), 'eas-argent-orchestration-home');
const ARGENT_STATE_DIR = path.join(TEST_HOME, '.argent');
Expand Down Expand Up @@ -130,6 +135,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock);

// The (real) tool-server state wait reads this file from the redirected ~/.argent.
await fs.promises.mkdir(ARGENT_STATE_DIR, { recursive: true });
Expand All @@ -143,6 +149,73 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
await fs.promises.rm(TEST_HOME, { recursive: true, force: true });
});

it.each([
[BuildRuntimePlatform.DARWIN, false],
[BuildRuntimePlatform.LINUX, false],
[BuildRuntimePlatform.DARWIN, true],
])(
'handles Stop during %s setup, keeping teardown failure=%s visible',
async (runtimePlatform, teardownFails) => {
let rejectSetup!: (error: Error) => void;
const setup = Object.assign(
new Promise<never>((_resolve, reject) => {
rejectSetup = reject;
}),
{ child: { pid: 12345 } }
);
jest.mocked(spawn).mockReturnValueOnce(setup as unknown as ReturnType<typeof spawn>);
jest
.mocked(killProcessGroup)
.mockImplementationOnce(() => rejectSetup(new Error('setup killed')));
const teardownError = new Error('host teardown failed');
if (teardownFails) {
mockPreviewStopAsync.mockRejectedValueOnce(teardownError);
}
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
const running = Promise.resolve(
buildFunction.fn!(
{
logger: { info: jest.fn(), warn: jest.fn() },
global: { runtimePlatform },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
boot_simulator: {
value: runtimePlatform === BuildRuntimePlatform.DARWIN ? true : undefined,
},
},
outputs: {},
env: {},
} as never
)
);
running.catch(() => {});
try {
for (let i = 0; i < 20 && !jest.mocked(spawn).mock.calls.length; i++) {
await new Promise(resolve => setImmediate(resolve));
}
expect(spawn).toHaveBeenCalledTimes(1);
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
watch.stop();
if (teardownFails) {
await expect(running).rejects.toBe(teardownError);
} else {
await expect(running).resolves.toBeUndefined();
}
expect(killProcessGroup).toHaveBeenCalledTimes(1);
expect(spawnDetached).not.toHaveBeenCalled();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
expect(watch.finishAsync).toHaveBeenCalled();
} finally {
rejectSetup(new Error('test cleanup'));
await running.catch(() => {});
}
}
);

it('reports an early exit with output and stops Argent before opening tunnels', async () => {
const stopError = new Error('drain timed out');
const stopServer = jest.fn().mockRejectedValue(stopError);
Expand Down Expand Up @@ -892,7 +965,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
});

it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'keeps %s prepared setup commands outside the new cancellation path',
'makes %s prepared setup commands cancellable',
async runtimePlatform => {
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
await buildFunction.fn!(
Expand All @@ -911,7 +984,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
);
expect(spawn).toHaveBeenCalledTimes(3);
for (const [, , options] of jest.mocked(spawn).mock.calls) {
expect(options).not.toHaveProperty('detached');
expect(options).toHaveProperty('detached', true);
expect(options).not.toHaveProperty('signal');
}
expect(killProcessGroup).not.toHaveBeenCalled();
Expand Down
Loading
Loading