Skip to content
This repository was archived by the owner on Feb 24, 2026. It is now read-only.
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
2 changes: 2 additions & 0 deletions packages/eas-build-job/src/__tests__/step.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ describe('StepZ', () => {
env: {
KEY1: 'value1',
},
'no-logs-warn-timeout-minutes': 15,
'no-logs-kill-timeout-minutes': 30,
};
expect(StepZ.parse(step)).toEqual(step);
});
Expand Down
7 changes: 7 additions & 0 deletions packages/eas-build-job/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,10 @@ export class CredentialsDistCertMismatchError extends UserFacingError {
super('EAS_BUILD_CREDENTIALS_DIST_CERT_MISMATCH', message);
}
}

export class SpawnCommandTimeoutError extends UserFacingError {
constructor(message?: string | undefined) {
const defaultMessage = 'Command timed out.';
super('EAS_BUILD_SPAWN_COMMAND_TIMEOUT', message ?? defaultMessage);
}
}
26 changes: 26 additions & 0 deletions packages/eas-build-job/src/step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ export const FunctionStepZ = CommonStepZ.extend({
run: z.never().optional(),
shell: z.never().optional(),
outputs: z.never().optional(),
'no-logs-warn-timeout-minutes': z.never().optional(),
'no-logs-kill-timeout-minutes': z.never().optional(),
});

export type FunctionStep = z.infer<typeof FunctionStepZ>;
Expand Down Expand Up @@ -137,6 +139,30 @@ export const ShellStepZ = CommonStepZ.extend({
])
)
.optional(),
/**
* Number of minutes since last log produced by the script, after which a warning will be logged.
*
* @example
* no-logs-warn-timeout-minutes: 15
*/
'no-logs-warn-timeout-minutes': z
.number()
.optional()
.describe(
'If the script does not produce any logs in this many minutes, a warning will be logged.'
),
/**
* Number of minutes since last log produced by the script, after which an error will be thrown and the process running the script will be terminated.
*
* @example
* no-logs-kill-timeout-minutes: 30
*/
'no-logs-kill-timeout-minutes': z
.number()
.optional()
.describe(
'If the script does not produce any logs in this many minutes, an error will be thrown and script will be terminated.'
),

uses: z.never().optional(),
with: z.never().optional(),
Expand Down
30 changes: 29 additions & 1 deletion packages/steps/src/BuildStep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import path from 'path';
import { Buffer } from 'buffer';

import { v4 as uuidv4 } from 'uuid';
import { JobInterpolationContext } from '@expo/eas-build-job';
import { JobInterpolationContext, errors } from '@expo/eas-build-job';

import { BuildStepContext, BuildStepGlobalContext } from './BuildStepContext.js';
import { BuildStepInput, BuildStepInputById, makeBuildStepInputByIdMap } from './BuildStepInput.js';
Expand Down Expand Up @@ -57,6 +57,8 @@ export type BuildStepFunction = (
const UUID_REGEX =
/^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/;

class BuildStepTimeoutError extends errors.SpawnCommandTimeoutError {}

export interface SerializedBuildStepOutputAccessor {
id: string;
executed: boolean;
Expand Down Expand Up @@ -142,6 +144,9 @@ export class BuildStep extends BuildStepOutputAccessor {
private readonly inputById: BuildStepInputById;
protected executed = false;

public readonly noLogsWarnTimeoutMinutes?: number;
public readonly noLogsKillTimeoutMinutes?: number;

public static getNewId(userDefinedId?: string): string {
return userDefinedId ?? uuidv4();
}
Expand Down Expand Up @@ -188,6 +193,8 @@ export class BuildStep extends BuildStepOutputAccessor {
supportedRuntimePlatforms: maybeSupportedRuntimePlatforms,
env,
ifCondition,
noLogsWarnTimeoutMinutes,
noLogsKillTimeoutMinutes,
}: {
id: string;
name?: string;
Expand All @@ -201,6 +208,8 @@ export class BuildStep extends BuildStepOutputAccessor {
supportedRuntimePlatforms?: BuildRuntimePlatform[];
env?: BuildStepEnv;
ifCondition?: string;
noLogsWarnTimeoutMinutes?: number;
noLogsKillTimeoutMinutes?: number;
}
) {
assert(command !== undefined || fn !== undefined, 'Either command or fn must be defined.');
Expand Down Expand Up @@ -234,6 +243,9 @@ export class BuildStep extends BuildStepOutputAccessor {
this.outputsDir = getTemporaryOutputsDirPath(ctx, this.id);
this.envsDir = getTemporaryEnvsDirPath(ctx, this.id);

this.noLogsWarnTimeoutMinutes = noLogsWarnTimeoutMinutes;
this.noLogsKillTimeoutMinutes = noLogsKillTimeoutMinutes;

ctx.registerStep(this);
}

Expand Down Expand Up @@ -360,6 +372,7 @@ export class BuildStep extends BuildStepOutputAccessor {
toJSON: (value: unknown) => JSON.stringify(value),
};
}

private async executeCommandAsync(): Promise<void> {
assert(this.command, 'Command must be defined.');

Expand Down Expand Up @@ -387,6 +400,21 @@ export class BuildStep extends BuildStepOutputAccessor {
env: this.getScriptEnv(),
// stdin is /dev/null, std{out,err} are piped into logger.
stdio: ['ignore', 'pipe', 'pipe'],
noLogsTimeout: {
warn: {
timeoutMinutes: this.noLogsWarnTimeoutMinutes ?? 15,
message:
'Command takes longer then expected and it did not produce any logs in the past 15 minutes. Consider evaluating your command for possible issues.',
},
kill: {
timeoutMinutes: this.noLogsKillTimeoutMinutes ?? 30,
message:
'Command takes a very long time and it did not produce any logs in the past 30 minutes. Most likely an unexpected error happened which caused the process to hang and it will be terminated.',
errorClass: BuildStepTimeoutError,
errorMessage:
'Command was inactive for over 30 minutes. Please evaluate if it is correct.',
},
},
});
this.ctx.logger.debug(`Script completed successfully`);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/steps/src/StepsConfigParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ export class StepsConfigParser extends AbstractConfigParser {
command: step.run,
env: step.env,
ifCondition: step.if,
noLogsWarnTimeoutMinutes: step['no-logs-warn-timeout-minutes'],
noLogsKillTimeoutMinutes: step['no-logs-kill-timeout-minutes'],
});
}

Expand Down
6 changes: 6 additions & 0 deletions packages/steps/src/__tests__/StepsConfigParser-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ describe(StepsConfigParser, () => {
},
],
if: '${ always() }',
'no-logs-warn-timeout-minutes': 5,
'no-logs-kill-timeout-minutes': 10,
},
{
id: 'step4',
Expand Down Expand Up @@ -322,6 +324,8 @@ describe(StepsConfigParser, () => {
expect(output3.id).toBe('my_optional_output_without_required');
expect(output3.required).toBe(true);
expect(step3.ifCondition).toBe('${ always() }');
expect(step3.noLogsWarnTimeoutMinutes).toBe(5);
expect(step3.noLogsKillTimeoutMinutes).toBe(10);

const step4 = result.buildSteps[3];
expect(step4.id).toEqual('step4');
Expand Down Expand Up @@ -370,6 +374,8 @@ describe(StepsConfigParser, () => {
expect(input4.required).toBe(true);
expect(step4.outputById).toStrictEqual({});
expect(step4.ifCondition).toBe('${ ctx.job.platform } == "android"');
expect(step4.noLogsWarnTimeoutMinutes).toBeUndefined();
expect(step4.noLogsKillTimeoutMinutes).toBeUndefined();
});
});
});
152 changes: 141 additions & 11 deletions packages/steps/src/utils/shell/spawn.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,90 @@
import { IOType } from 'child_process';

import { pipeSpawnOutput, bunyan, PipeMode } from '@expo/logger';
import { pipeSpawnOutput, bunyan, PipeOptions } from '@expo/logger';
import spawnAsyncOriginal, {
SpawnResult,
SpawnPromise,
SpawnOptions as SpawnOptionsOriginal,
} from '@expo/spawn-async';
import { errors } from '@expo/eas-build-job';

import { nullthrows } from '../nullthrows.js';

interface IErrorClass {
new (message?: string | undefined): errors.SpawnCommandTimeoutError;
}

type NoLogsTimeoutOptions = {
warn?: {
timeoutMinutes?: number;
message?: string;
};
kill?: {
timeoutMinutes?: number;
message?: string;
errorClass?: IErrorClass;
errorMessage?: string;
Comment on lines +25 to +26
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is there a reason we need to customize errorClass and errorMessage? Couldn't we just pass in warnTimeoutMs and killTimeoutMs?

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.

We don't need to, if not passed a default error with a default message will be thrown. But I thought it might be useful to customise them depending on where the functionality is used (workflow command, build step, etc.) and have the appropriate message

};
};

// We omit 'ignoreStdio' to simplify logic -- only 'stdio' governs stdio.
// We omit 'stdio' here to add further down in a logger-based union.
type SpawnOptions = Omit<SpawnOptionsOriginal, 'stdio' | 'ignoreStdio'> & {
lineTransformer?: (line: string) => string | null;
mode?: PipeMode;
} & (
type SpawnOptions = Omit<SpawnOptionsOriginal, 'stdio' | 'ignoreStdio'> &
PipeOptions &
(
| {
// If logger is passed, we require stdio to be pipe.
logger: bunyan;
stdio: 'pipe' | [IOType, 'pipe', 'pipe', ...IOType[]];
noLogsTimeout?: NoLogsTimeoutOptions;
}
| {
// If logger is not passed, stdio can be anything.
// Defaults to inherit.
logger?: never;
stdio?: SpawnOptionsOriginal['stdio'];
noLogsTimeout?: undefined;
}
);
// If

const SPAWN_WARN_TIMEOUT_DEFAULT_MINUTES = 15;
const SPAWN_WARN_TIMEOUT_DEFAULT_MESSAGE =
'Command takes longer then expected and it did not produce any logs in the past ${minutes} minutes. Consider evaluating your command for possible issues.';
const SPAWN_KILL_TIMEOUT_DEFAULT_MINUTES = 30;
const SPAWN_KILL_TIMEOUT_DEFAULT_MESSAGE =
'Command takes a very long time and it did not produce any logs in the past ${minutes} minutes. Most likely an unexpected error happened which caused the process to hang and it will be terminated.';
const SPAWN_KILL_TIMEOUT_DEFAULT_ERROR_MESSAGE =
'Command was inactive for over ${minutes} minutes. Please evaluate if it is correct.';

function getWarnTimeoutMessage(noLogsTimeout: NoLogsTimeoutOptions): string {
const warnTimeoutMinutes =
noLogsTimeout.warn?.timeoutMinutes ?? SPAWN_WARN_TIMEOUT_DEFAULT_MINUTES;
return (
noLogsTimeout.warn?.message ??
SPAWN_WARN_TIMEOUT_DEFAULT_MESSAGE.replace('${minutes}', warnTimeoutMinutes.toString())
);
}

function getKillTimeoutMessage(noLogsTimeout: NoLogsTimeoutOptions): string {
const killTimeoutMinutes =
noLogsTimeout.kill?.timeoutMinutes ?? SPAWN_KILL_TIMEOUT_DEFAULT_MINUTES;
return (
noLogsTimeout.kill?.message ??
SPAWN_KILL_TIMEOUT_DEFAULT_MESSAGE.replace('${minutes}', killTimeoutMinutes.toString())
);
}

function getKillTimeoutError(
noLogsTimeout: NoLogsTimeoutOptions | undefined
): errors.SpawnCommandTimeoutError {
const spawnKillTimeout =
noLogsTimeout?.kill?.timeoutMinutes ?? SPAWN_KILL_TIMEOUT_DEFAULT_MINUTES;
const errorMessage =
noLogsTimeout?.kill?.errorMessage ??
SPAWN_KILL_TIMEOUT_DEFAULT_ERROR_MESSAGE.replace('${minutes}', spawnKillTimeout.toString());
const ErrorClass = noLogsTimeout?.kill?.errorClass ?? errors.SpawnCommandTimeoutError;
return new ErrorClass(errorMessage);
}

// eslint-disable-next-line async-protect/async-suffix
export function spawnAsync(
Expand All @@ -36,10 +95,81 @@ export function spawnAsync(
cwd: process.cwd(),
}
): SpawnPromise<SpawnResult> {
const { logger, ...options } = allOptions;
const promise = spawnAsyncOriginal(command, args, options);
if (logger && promise.child) {
pipeSpawnOutput(logger, promise.child, options);
const { logger, noLogsTimeout, ...options } = allOptions;
let spawnWarnTimeout: NodeJS.Timeout | undefined;
let spawnKillTimeout: NodeJS.Timeout | undefined;
let spawnTimedOut: boolean = false;

function setCommandSpawnTimeouts(
noLogsTimeout: NoLogsTimeoutOptions,
logger: bunyan,
spawnPromise: SpawnPromise<SpawnResult>
): void {
if (noLogsTimeout.warn) {
const warnTimeoutMinutes =
noLogsTimeout.warn.timeoutMinutes ?? SPAWN_WARN_TIMEOUT_DEFAULT_MINUTES;
spawnWarnTimeout = setTimeout(
() => {
logger.warn(getWarnTimeoutMessage(noLogsTimeout));
},
warnTimeoutMinutes * 60 * 1000
);
}

if (noLogsTimeout.kill) {
const killTimeoutMinutes =
noLogsTimeout.kill.timeoutMinutes ?? SPAWN_KILL_TIMEOUT_DEFAULT_MINUTES;
spawnKillTimeout = setTimeout(
async () => {
spawnTimedOut = true;
logger.error(getKillTimeoutMessage(noLogsTimeout));
const ppid = nullthrows(spawnPromise.child.pid);
process.kill(ppid);
},
killTimeoutMinutes * 60 * 1000
);
}
}

const spawnPromise = spawnAsyncOriginal(command, args, options);
if (logger && spawnPromise.child) {
if (noLogsTimeout) {
const optionsWithCallback = {
...options,
infoCallbackFn: () => {
if (spawnWarnTimeout) {
spawnWarnTimeout.refresh();
}
if (spawnKillTimeout) {
spawnKillTimeout.refresh();
}
},
};
pipeSpawnOutput(logger, spawnPromise.child, optionsWithCallback);
setCommandSpawnTimeouts(noLogsTimeout, logger, spawnPromise);
} else {
pipeSpawnOutput(logger, spawnPromise.child, options);
}
}
return promise;
spawnPromise
.then(
(spawnResult) => spawnResult,
(_) => {}
)
.catch((err: any) => {
if (spawnTimedOut) {
throw getKillTimeoutError(noLogsTimeout);
}
throw err;
})
.finally(() => {
if (spawnWarnTimeout) {
clearTimeout(spawnWarnTimeout);
}
if (spawnKillTimeout) {
clearTimeout(spawnKillTimeout);
}
});

return spawnPromise;
}