Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## unreleased

- Add a `settle_timeout_seconds` option (unset by default, preserving current
behavior) to optionally wait, after boot, for the Simulator's background
daemon-spawning CPU burst to subside before continuing.

## v5

- Fix flaky boot issues by adding a retry parameter (#563).
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ a device with Apple Developer account, because a Simulator UDID
| `wait_for_boot` | `false` | Whether the action must wait for the Simulator to finish booting requested image |
| `boot_timeout_seconds` | `360` | Maximum number of seconds to wait for the Simulator to finish booting (0 disables the timeout) |
| `boot_retries` | `2` | Number of times to retry booting when waiting for the Simulator to finish booting fails. Setting this to 2 will result in 3 attempts: one normal attempt and two retries. |
| `settle_timeout_seconds` | `(unset)` | Maximum number of seconds to wait, after boot, for the Simulator's background CPU usage (daemons spawned by launchd_sim) to settle down before continuing. Leave unset (default) or set to 0 to skip this check entirely, preserving prior behavior -- nothing is logged either way. When set to a positive value, if the Simulator hasn't settled within that time, a warning is logged and the action continues without failing the job. |
| `settle_check_interval_seconds` | `2` | Number of seconds between checks of the Simulator's background CPU usage. Only relevant when `settle_timeout_seconds` is set to a positive value. |
| `settle_cpu_threshold_percent` | `20` | Aggregate %CPU (summed across all direct children of launchd_sim) below which the Simulator is considered settled. Only relevant when `settle_timeout_seconds` is set to a positive value. |
| `settle_consecutive_samples` | `3` | Number of consecutive checks below `settle_cpu_threshold_percent` required before the Simulator is considered settled. Only relevant when `settle_timeout_seconds` is set to a positive value. |
| `shutdown_after_job` | `true` | Whether to shutdown the launched Simulator after the workflow job has been finished |

## Outputs
Expand Down
46 changes: 42 additions & 4 deletions __tests__/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,33 @@ import * as cp from 'child_process'
import * as path from 'path'
import * as process from 'process'

// The available iOS device lineup shifts as the CI runner's Xcode version
// changes, so a hardcoded model (e.g. a fixed "iPhone 16") can disappear.
// Look up a model that's actually installed instead.
function findAnyAvailableIOSModel(): string {
const devices = JSON.parse(
cp
.execFileSync('xcrun', [
'simctl',
'list',
'devices',
'available',
'--json'
])
.toString()
).devices as {[runtime: string]: {name: string}[]}

for (const [runtime, runtimeDevices] of Object.entries(devices)) {
if (runtime.includes('SimRuntime.iOS') && runtimeDevices.length > 0) {
return runtimeDevices[0].name
}
}

throw new Error(
'No available iOS Simulator devices found to run the test against'
)
}

test('boots a device', () => {
process.env['INPUT_OS_VERSION'] = '>=10.0'
const nodeProcess = process.execPath
Expand All @@ -10,11 +37,14 @@ test('boots a device', () => {
env: process.env
}

expect(
cp.execFileSync(nodeProcess, [actionMain], options).toString()
).toContain('Booting device')
const firstRunOutput = cp
.execFileSync(nodeProcess, [actionMain], options)
.toString()
expect(firstRunOutput).toContain('Booting device')
expect(firstRunOutput).not.toContain('Waiting for the Simulator to settle')

process.env['INPUT_MODEL'] = 'iphone 16'
// lower-cased on purpose: also exercises the case-insensitive model match
process.env['INPUT_MODEL'] = findAnyAvailableIOSModel().toLowerCase()
expect(
cp.execFileSync(nodeProcess, [actionMain], options).toString()
).toContain('Booting device')
Expand All @@ -24,6 +54,14 @@ test('boots a device', () => {
cp.execFileSync(nodeProcess, [actionMain], options).toString()
).toContain('Waiting for device to finish booting')

process.env['INPUT_SETTLE_TIMEOUT_SECONDS'] = '5'
process.env['INPUT_SETTLE_CHECK_INTERVAL_SECONDS'] = '1'
expect(
cp.execFileSync(nodeProcess, [actionMain], options).toString()
).toContain('Waiting for the Simulator to settle')
delete process.env['INPUT_SETTLE_TIMEOUT_SECONDS']
delete process.env['INPUT_SETTLE_CHECK_INTERVAL_SECONDS']

process.env['INPUT_MODEL'] = 'Pixel 4'
expect(() => cp.execFileSync(nodeProcess, [actionMain], options)).toThrow()
})
66 changes: 66 additions & 0 deletions __tests__/settle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {computeSettleCpu} from '../src/settle'

const UDID = 'AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE'

function psLine(
pid: number,
ppid: number,
pcpu: number,
command: string
): string {
return `${pid} ${ppid} ${pcpu} ${command}`
}

test('sums only direct children of the target udid launchd_sim', () => {
const psOutput = [
psLine(1, 0, 0.0, '/sbin/launchd'),
psLine(100, 1, 0.0, `/path/to/launchd_sim ${UDID}`),
psLine(101, 100, 30.5, 'com.apple.analyticsd'),
psLine(102, 100, 15.2, 'com.apple.mobileassetd')
].join('\n')

expect(computeSettleCpu(psOutput, UDID)).toBeCloseTo(45.7)
})

test('returns undefined when the udid is not found', () => {
const psOutput = [
psLine(1, 0, 0.0, '/sbin/launchd'),
psLine(100, 1, 0.0, `/path/to/launchd_sim SOME-OTHER-UDID`),
psLine(101, 100, 30.5, 'com.apple.analyticsd')
].join('\n')

expect(computeSettleCpu(psOutput, UDID)).toBeUndefined()
})

test('a second simulator launchd_sim tree does not leak into the sum', () => {
const OTHER_UDID = '11111111-2222-3333-4444-555555555555'
const psOutput = [
psLine(1, 0, 0.0, '/sbin/launchd'),
psLine(100, 1, 0.0, `/path/to/launchd_sim ${UDID}`),
psLine(101, 100, 10.0, 'com.apple.analyticsd'),
psLine(200, 1, 0.0, `/path/to/launchd_sim ${OTHER_UDID}`),
psLine(201, 200, 90.0, 'com.apple.mobileassetd')
].join('\n')

expect(computeSettleCpu(psOutput, UDID)).toBeCloseTo(10.0)
})

test('grandchildren are excluded, only direct children counted', () => {
const psOutput = [
psLine(1, 0, 0.0, '/sbin/launchd'),
psLine(100, 1, 0.0, `/path/to/launchd_sim ${UDID}`),
psLine(101, 100, 10.0, 'com.apple.analyticsd'),
psLine(102, 101, 99.0, 'grandchild-process')
].join('\n')

expect(computeSettleCpu(psOutput, UDID)).toBeCloseTo(10.0)
})

test('returns 0 (not undefined) when launchd_sim exists but has no children yet', () => {
const psOutput = [
psLine(1, 0, 0.0, '/sbin/launchd'),
psLine(100, 1, 0.0, `/path/to/launchd_sim ${UDID}`)
].join('\n')

expect(computeSettleCpu(psOutput, UDID)).toBe(0)
})
31 changes: 31 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,37 @@ inputs:
booting fails.
required: false
default: '3'
settle_timeout_seconds:
description: >
Maximum number of seconds to wait, after boot, for the Simulator's
background CPU usage (daemons spawned by launchd_sim) to settle down
before continuing. Leave unset (default) to skip this check entirely,
preserving prior behavior. Set to 0 to skip it explicitly. If the
Simulator hasn't settled within this time, a warning is logged and the
action continues without failing the job.
required: false
default: ''
settle_check_interval_seconds:
description: >
Number of seconds between checks of the Simulator's background CPU
usage. Only relevant when settle_timeout_seconds is set to a positive
value.
required: false
default: '2'
settle_cpu_threshold_percent:
description: >
Aggregate %CPU (summed across all direct children of launchd_sim)
below which the Simulator is considered settled. Only relevant when
settle_timeout_seconds is set to a positive value.
required: false
default: '20'
settle_consecutive_samples:
description: >
Number of consecutive checks below settle_cpu_threshold_percent
required before the Simulator is considered settled. Only relevant
when settle_timeout_seconds is set to a positive value.
required: false
default: '3'
shutdown_after_job:
description: >
Whether the Simulator should be shut down after the job has finished. This
Expand Down
138 changes: 138 additions & 0 deletions dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as core from '@actions/core'
import {boolean} from 'boolean'
import * as semver from 'semver'
import {deviceToString, getDevices, simctl} from './xcrun'
import {waitForSettle} from './settle'

async function run(): Promise<void> {
try {
Expand Down Expand Up @@ -112,6 +113,25 @@ async function run(): Promise<void> {
}
}

const settleTimeoutSeconds = Number(
core.getInput('settle_timeout_seconds') || '0'
)
if (settleTimeoutSeconds > 0) {
core.info(
'Waiting for the Simulator to settle (background daemons to finish starting).'
)
await waitForSettle({
udid: device.udid,
cpuThresholdPercent: Number(
core.getInput('settle_cpu_threshold_percent')
),
consecutiveSamples: Number(core.getInput('settle_consecutive_samples')),
checkIntervalMs:
Number(core.getInput('settle_check_interval_seconds')) * 1000,
timeoutMs: settleTimeoutSeconds * 1000
})
}

core.setOutput('udid', device.udid)
} catch (error) {
let errorMessage = 'Failed to run simulator-action (reason unknown)'
Expand Down
Loading
Loading