Compare commits

...
Author SHA1 Message Date
Ash Wu 1ca33ad191 address comments
Signed-off-by: Ash Wu <[email protected]>
2026-06-08 19:16:15 +08:00
Ash Wu 14d5cb1ac5 [build-tools] fix worker crash when xcpretty formatter throws on xcodebuild output
Signed-off-by: Ash Wu <[email protected]>
2026-06-03 15:49:12 +08:00
6 changed files with 128 additions and 139 deletions

No files matched your search

@@ -0,0 +1,94 @@
import { EventEmitter } from 'events';
import spawnAsync from '@expo/spawn-async';
import { ExpoRunFormatter } from '@expo/xcpretty';
import fg from 'fast-glob';
import { createMockLogger } from '../../__tests__/utils/logger';
import { Sentry } from '../../sentry';
import { XcodeBuildLogger } from '../xcpretty';
jest.mock('@expo/spawn-async');
jest.mock('@expo/xcpretty');
jest.mock('fast-glob');
jest.mock('../../sentry', () => ({
Sentry: {
setup: jest.fn(),
capture: jest.fn(),
flush: jest.fn(),
},
}));
describe(XcodeBuildLogger, () => {
let stdout: EventEmitter;
let formatter: { pipe: jest.Mock; getBuildSummary: jest.Mock };
beforeEach(() => {
jest.clearAllMocks();
stdout = new EventEmitter();
const spawnPromise = Object.assign(new Promise(() => {}), {
child: { stdout, kill: jest.fn() },
});
jest.mocked(spawnAsync).mockReturnValue(spawnPromise as any);
formatter = {
pipe: jest.fn(),
getBuildSummary: jest.fn().mockReturnValue('summary'),
};
jest.mocked(ExpoRunFormatter.create).mockReturnValue(formatter as any);
jest.mocked(fg).mockResolvedValue(['build.log'] as any);
});
async function startWatchingAsync(logger: ReturnType<typeof createMockLogger>): Promise<void> {
const buildLogger = new XcodeBuildLogger(logger, '/project');
await buildLogger.watchLogFiles('/logs');
}
it('logs formatted lines', async () => {
const logger = createMockLogger();
await startWatchingAsync(logger);
formatter.pipe.mockReturnValue(['line1', 'line2']);
stdout.emit('data', 'raw xcodebuild output');
expect(formatter.pipe).toHaveBeenCalledWith('raw xcodebuild output');
expect(logger.info).toHaveBeenCalledWith('line1');
expect(logger.info).toHaveBeenCalledWith('line2');
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('falls back to raw logs when the formatter throws', async () => {
const logger = createMockLogger();
await startWatchingAsync(logger);
formatter.pipe.mockImplementation(() => {
throw new RangeError('Invalid array length');
});
stdout.emit('data', 'offending output');
expect(logger.info).toHaveBeenCalledWith('offending output');
});
it('reports the formatter error to Sentry only once', async () => {
const logger = createMockLogger();
await startWatchingAsync(logger);
const error = new RangeError('Invalid array length');
formatter.pipe.mockImplementation(() => {
throw error;
});
stdout.emit('data', 'offending output');
stdout.emit('data', 'more offending output');
expect(logger.info).toHaveBeenCalledWith('offending output');
expect(logger.info).toHaveBeenCalledWith('more offending output');
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
'xcpretty formatter failed to parse xcodebuild logs',
error,
{ extras: { data: 'offending output' } }
);
});
});
@@ -6,11 +6,14 @@ import fg from 'fast-glob';
import fs from 'fs-extra';
import path from 'path';
import { Sentry } from '../sentry';
const CHECK_FILE_INTERVAL_MS = 1000;
export class XcodeBuildLogger {
private loggerError?: Error;
private flushing: boolean = false;
private reportedFormatterError: boolean = false;
private logReaderPromise?: SpawnPromise<SpawnResult>;
private logsPath?: string;
@@ -60,9 +63,21 @@ export class XcodeBuildLogger {
this.logReaderPromise = spawnAsync('tail', ['-n', '+0', '-f', logsPath], { stdio: 'pipe' });
assert(this.logReaderPromise.child.stdout, 'stdout is not available');
this.logReaderPromise.child.stdout.on('data', (data: string) => {
const lines = formatter.pipe(data.toString());
for (const line of lines) {
this.logger.info(line);
try {
const lines = formatter.pipe(data.toString());
for (const line of lines) {
this.logger.info(line);
}
} catch (err: any) {
// The formatter can throw on unexpected xcodebuild output. Fall back to
// raw logs instead of crashing the process with an uncaught exception.
if (!this.reportedFormatterError) {
this.reportedFormatterError = true;
Sentry.capture('xcpretty formatter failed to parse xcodebuild logs', err, {
extras: { data: data.toString().slice(-2000) },
});
}
this.logger.info(data.toString());
}
});
await this.logReaderPromise;
+1 -1
View File
@@ -10,8 +10,8 @@ import type { Credentials } from './credentials/manager';
import { createFastfileForResigningBuild } from './fastfile';
import { createGymfileForArchiveBuild, createGymfileForSimulatorBuild } from './gymfile';
import { isTVOS } from './tvos';
import { XcodeBuildLogger } from './xcpretty';
import { COMMON_FASTLANE_ENV } from '../common/fastlane';
import { XcodeBuildLogger } from '../common/xcpretty';
import { BuildContext, SkipNativeBuildError } from '../context';
export async function runFastlaneGym<TJob extends Ios.Job>(
@@ -3,8 +3,8 @@ import { BuildStepEnv } from '@expo/steps';
import spawn, { SpawnResult } from '@expo/turtle-spawn';
import path from 'path';
import { XcodeBuildLogger } from './xcpretty';
import { COMMON_FASTLANE_ENV } from '../../../common/fastlane';
import { XcodeBuildLogger } from '../../../common/xcpretty';
export async function runFastlaneGym({
workingDir,
@@ -1,91 +0,0 @@
import { bunyan } from '@expo/logger';
import spawnAsync, { SpawnPromise, SpawnResult } from '@expo/spawn-async';
import { ExpoRunFormatter } from '@expo/xcpretty';
import assert from 'assert';
import fg from 'fast-glob';
import fs from 'fs-extra';
import path from 'path';
const CHECK_FILE_INTERVAL_MS = 1000;
export class XcodeBuildLogger {
private loggerError?: Error;
private flushing: boolean = false;
private logReaderPromise?: SpawnPromise<SpawnResult>;
private logsPath?: string;
constructor(
private readonly logger: bunyan,
private readonly projectRoot: string
) {}
public async watchLogFiles(logsDirectory: string): Promise<void> {
while (!this.flushing) {
const logsFilename = await this.getBuildLogFilename(logsDirectory);
if (logsFilename) {
this.logsPath = path.join(logsDirectory, logsFilename);
void this.startBuildLogger(this.logsPath);
return;
}
await new Promise(res => setTimeout(res, CHECK_FILE_INTERVAL_MS));
}
}
public async flush(): Promise<void> {
this.flushing = true;
if (this.loggerError) {
throw this.loggerError;
}
if (this.logReaderPromise) {
this.logReaderPromise.child.kill('SIGINT');
try {
await this.logReaderPromise;
} catch {}
}
if (this.logsPath) {
await this.findBundlerErrors(this.logsPath);
}
}
private async getBuildLogFilename(logsDirectory: string): Promise<string | undefined> {
const paths = await fg('*.log', { cwd: logsDirectory });
return paths.length >= 1 ? paths[0] : undefined;
}
private async startBuildLogger(logsPath: string): Promise<void> {
try {
const formatter = ExpoRunFormatter.create(this.projectRoot, {
// TODO: Can provide xcode project name for better parsing
isDebug: false,
});
this.logReaderPromise = spawnAsync('tail', ['-n', '+0', '-f', logsPath], { stdio: 'pipe' });
assert(this.logReaderPromise.child.stdout, 'stdout is not available');
this.logReaderPromise.child.stdout.on('data', (data: string) => {
const lines = formatter.pipe(data.toString());
for (const line of lines) {
this.logger.info(line);
}
});
await this.logReaderPromise;
this.logger.info(formatter.getBuildSummary());
} catch (err: any) {
if (!this.flushing) {
this.loggerError = err;
}
}
}
private async findBundlerErrors(logsPath: string): Promise<void> {
try {
const logFile = await fs.readFile(logsPath, 'utf-8');
const match = logFile.match(
/Welcome to Metro!\s* Fast - Scalable - Integrated\s*([\s\S]*)Run CLI with --verbose flag for more details.\nCommand PhaseScriptExecution failed with a nonzero exit code/
);
if (match) {
this.logger.info(match[1]);
}
} catch (err) {
this.logger.error({ err }, 'Failed to read Xcode logs');
}
}
}
Generated
+14 -43
View File
@@ -789,15 +789,6 @@ __metadata:
languageName: node
linkType: hard
"@babel/code-frame@npm:7.10.4, @babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:~7.10.4":
version: 7.10.4
resolution: "@babel/code-frame@npm:7.10.4"
dependencies:
"@babel/highlight": "npm:^7.10.4"
checksum: 10c0/69e0f52986a1f40231d891224f420436629b6678711b68c088e97b7bdba1607aeb5eb9cfb070275c433f0bf43c37c134845db80d1cdbf5ac88a69b0bdcce9402
languageName: node
linkType: hard
"@babel/code-frame@npm:7.23.5, @babel/code-frame@npm:^7.23.5":
version: 7.23.5
resolution: "@babel/code-frame@npm:7.23.5"
@@ -808,6 +799,15 @@ __metadata:
languageName: node
linkType: hard
"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:~7.10.4":
version: 7.10.4
resolution: "@babel/code-frame@npm:7.10.4"
dependencies:
"@babel/highlight": "npm:^7.10.4"
checksum: 10c0/69e0f52986a1f40231d891224f420436629b6678711b68c088e97b7bdba1607aeb5eb9cfb070275c433f0bf43c37c134845db80d1cdbf5ac88a69b0bdcce9402
languageName: node
linkType: hard
"@babel/code-frame@npm:^7.12.13":
version: 7.12.13
resolution: "@babel/code-frame@npm:7.12.13"
@@ -3614,16 +3614,15 @@ __metadata:
linkType: soft
"@expo/xcpretty@npm:^4.3.1":
version: 4.3.2
resolution: "@expo/xcpretty@npm:4.3.2"
version: 4.4.4
resolution: "@expo/xcpretty@npm:4.4.4"
dependencies:
"@babel/code-frame": "npm:7.10.4"
"@babel/code-frame": "npm:^7.20.0"
chalk: "npm:^4.1.0"
find-up: "npm:^5.0.0"
js-yaml: "npm:^4.1.0"
bin:
excpretty: build/cli.js
checksum: 10c0/e524817b2e42fb8c8914fca7e8f7c2f723f4f6d338a57b7ae97cd3e76da8108af63a22d4c7dc2e96a192a248a242f6e0f8056f0ca53bc4fb5cd2e5ae428e0891
checksum: 10c0/cd555ad49438dee2cc3f2950ecbef3048f7169bbdadc8db169cfcddaad13668fee6377c010624ed4079dc439c46d6023d2551da403a2070deec000bc864e8dd8
languageName: node
linkType: hard
@@ -12119,16 +12118,6 @@ __metadata:
languageName: node
linkType: hard
"find-up@npm:^5.0.0":
version: 5.0.0
resolution: "find-up@npm:5.0.0"
dependencies:
locate-path: "npm:^6.0.0"
path-exists: "npm:^4.0.0"
checksum: 10c0/062c5a83a9c02f53cdd6d175a37ecf8f87ea5bbff1fdfb828f04bfa021441bc7583e8ebc0872a4c1baab96221fb8a8a275a19809fb93fbc40bd69ec35634069a
languageName: node
linkType: hard
"find-yarn-workspace-root@npm:^2.0.0":
version: 2.0.0
resolution: "find-yarn-workspace-root@npm:2.0.0"
@@ -15375,15 +15364,6 @@ __metadata:
languageName: node
linkType: hard
"locate-path@npm:^6.0.0":
version: 6.0.0
resolution: "locate-path@npm:6.0.0"
dependencies:
p-locate: "npm:^5.0.0"
checksum: 10c0/d3972ab70dfe58ce620e64265f90162d247e87159b6126b01314dd67be43d50e96a50b517bce2d9452a79409c7614054c277b5232377de50416564a77ac7aad3
languageName: node
linkType: hard
"lodash.clonedeep@npm:^4.5.0":
version: 4.5.0
resolution: "lodash.clonedeep@npm:4.5.0"
@@ -17349,7 +17329,7 @@ __metadata:
languageName: node
linkType: hard
"p-limit@npm:3.1.0, p-limit@npm:^3.0.1, p-limit@npm:^3.0.2, p-limit@npm:^3.1.0":
"p-limit@npm:3.1.0, p-limit@npm:^3.0.1, p-limit@npm:^3.1.0":
version: 3.1.0
resolution: "p-limit@npm:3.1.0"
dependencies:
@@ -17394,15 +17374,6 @@ __metadata:
languageName: node
linkType: hard
"p-locate@npm:^5.0.0":
version: 5.0.0
resolution: "p-locate@npm:5.0.0"
dependencies:
p-limit: "npm:^3.0.2"
checksum: 10c0/2290d627ab7903b8b70d11d384fee714b797f6040d9278932754a6860845c4d3190603a0772a663c8cb5a7b21d1b16acb3a6487ebcafa9773094edc3dfe6009a
languageName: node
linkType: hard
"p-map-series@npm:2.1.0":
version: 2.1.0
resolution: "p-map-series@npm:2.1.0"