From 8ec7bd502754208b8baae64722df9cd4d09a2299 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Thu, 24 Sep 2026 18:26:52 -0700 Subject: [PATCH 1/2] [build-tools] replace record-sim with serve-sim recording --- CHANGELOG.md | 2 + packages/build-tools/package.json | 1 - .../resources/record-sim/.gitignore | 4 - .../resources/record-sim/Package.swift | 28 - .../resources/record-sim/README.md | 79 --- .../RecordSim/FramebufferDisplaySource.swift | 192 ------ .../RecordSim/PrivateSimulatorServices.swift | 96 --- .../Sources/RecordSim/RecordingModels.swift | 77 --- .../RecordSim/RecordingOutputWriter.swift | 140 ---- .../Sources/RecordSim/SimulatorRecorder.swift | 644 ------------------ .../Sources/RecordSim/Utilities.swift | 60 -- .../record-sim/Sources/record-sim/main.swift | 159 ----- .../steps/utils/IosSimulatorRecordingUtils.ts | 262 +++++-- .../IosSimulatorRecordingUtils.test.ts | 193 ++++++ .../__tests__/remoteDeviceRunSession.test.ts | 60 ++ .../src/steps/utils/localEgressGuard.ts | 1 - .../src/steps/utils/remoteDeviceRunSession.ts | 42 +- packages/worker/package.sh | 24 +- 18 files changed, 493 insertions(+), 1571 deletions(-) delete mode 100644 packages/build-tools/resources/record-sim/.gitignore delete mode 100644 packages/build-tools/resources/record-sim/Package.swift delete mode 100644 packages/build-tools/resources/record-sim/README.md delete mode 100644 packages/build-tools/resources/record-sim/Sources/RecordSim/FramebufferDisplaySource.swift delete mode 100644 packages/build-tools/resources/record-sim/Sources/RecordSim/PrivateSimulatorServices.swift delete mode 100644 packages/build-tools/resources/record-sim/Sources/RecordSim/RecordingModels.swift delete mode 100644 packages/build-tools/resources/record-sim/Sources/RecordSim/RecordingOutputWriter.swift delete mode 100644 packages/build-tools/resources/record-sim/Sources/RecordSim/SimulatorRecorder.swift delete mode 100644 packages/build-tools/resources/record-sim/Sources/RecordSim/Utilities.swift delete mode 100644 packages/build-tools/resources/record-sim/Sources/record-sim/main.swift create mode 100644 packages/build-tools/src/steps/utils/__tests__/IosSimulatorRecordingUtils.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b45f42e41d..ad2871d536 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐ŸŽ‰ New features +- [build-tools] Record iOS Simulator sessions through serve-sim using one framebuffer capture and a dedicated hardware encoder. ([#4474](https://github.com/expo/eas-cli/pull/4474) by [@gwdp](https://github.com/gwdp)) + ### ๐Ÿ› Bug fixes ### ๐Ÿงน Chores diff --git a/packages/build-tools/package.json b/packages/build-tools/package.json index ffb002da4e..ce4c560af1 100644 --- a/packages/build-tools/package.json +++ b/packages/build-tools/package.json @@ -23,7 +23,6 @@ "watch": "tsc --watch --preserveWatchOutput", "prebuild": "yarn gql", "build": "tsc", - "build:record-sim": "mkdir -p bin && record_sim_bin_path=$(swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build --show-bin-path) && swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build && cp \"$record_sim_bin_path/record-sim\" bin/record-sim && chmod +x bin/record-sim", "build:egress-guard": "resources/egress-guard/build.sh", "test:egress-guard": "resources/egress-guard/tests/run-policy-tests.sh && resources/egress-guard/tests/run-guard-tests.sh", "typecheck": "tsc", diff --git a/packages/build-tools/resources/record-sim/.gitignore b/packages/build-tools/resources/record-sim/.gitignore deleted file mode 100644 index 7bbbc6e501..0000000000 --- a/packages/build-tools/resources/record-sim/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -.build/ -.swiftpm/ -DerivedData/ -.DS_Store diff --git a/packages/build-tools/resources/record-sim/Package.swift b/packages/build-tools/resources/record-sim/Package.swift deleted file mode 100644 index e33d36d116..0000000000 --- a/packages/build-tools/resources/record-sim/Package.swift +++ /dev/null @@ -1,28 +0,0 @@ -// swift-tools-version: 5.9 - -import PackageDescription - -let package = Package( - name: "record-sim", - platforms: [ - .macOS(.v14), - ], - products: [ - .library(name: "RecordSim", targets: ["RecordSim"]), - .executable(name: "record-sim", targets: ["record-sim"]), - ], - targets: [ - .target( - name: "RecordSim", - linkerSettings: [ - .linkedFramework("AVFoundation"), - .linkedFramework("CoreGraphics"), - .linkedFramework("CoreMedia"), - .linkedFramework("CoreVideo"), - .linkedFramework("IOSurface"), - .linkedFramework("UniformTypeIdentifiers"), - ] - ), - .executableTarget(name: "record-sim", dependencies: ["RecordSim"]), - ] -) diff --git a/packages/build-tools/resources/record-sim/README.md b/packages/build-tools/resources/record-sim/README.md deleted file mode 100644 index ec86b6e0a3..0000000000 --- a/packages/build-tools/resources/record-sim/README.md +++ /dev/null @@ -1,79 +0,0 @@ -# RecordSim - -Small Swift library for non-exclusive iOS Simulator screen recording. - -It implements one recording path: - -1. Listen to SimRenderServer framebuffer callbacks. -2. Copy the live `IOSurface` immediately into an owned `CVPixelBuffer`. -3. Feed one continuous `AVAssetWriter`. -4. Emit Apple HLS/CMAF-style fragmented MP4 segments. - -The output is suitable for uploading while recording. `session.json` contains -the ordered segment metadata needed to build a playlist later. - -## Output - -```text -session/ - init.mp4 - session.json - segments/ - segment-000000.m4s - segment-000001.m4s -``` - -`init.mp4` is the fMP4 initialization segment. Individual `.m4s` files are not -standalone MP4 files. `session.json` stores the wall-clock timestamp for the -first video frame plus the metadata needed to build a playlist: HLS version, -target duration, media sequence, `initSegment`, and the ordered `segments` array -with file path and duration for each media segment. - -## CLI - -```bash -swift run record-sim \ - --udid \ - --output /tmp/sim-recording \ - --segment-duration 120 \ - --fps 30 \ - --bitrate 30000000 \ - --codec h264 -``` - -For sparse 2h sessions, `--segment-duration 120` is a reasonable default: about -60 media objects plus the init segment. The recorder appends a single duplicate -hold frame near each segment boundary when the simulator is idle, so long idle -periods still produce uploadable segments without encoding idle frames at 60fps. -Use `--segment-duration 0` to write one continuous `recording.mp4` instead of -`init.mp4` plus media segments. - -Frame timestamps use monotonic host time, not wall-clock time. The recorder also -probes the framebuffer seed once per second; if the surface changes without -callbacks for 5 seconds, it captures that frame and rewires the private callback -registration. - -## Library - -```swift -let recorder = SimulatorRecorder( - configuration: SimulatorRecordingConfiguration( - deviceUDID: udid, - outputDirectory: sessionDirectory, - segmentDuration: 120 - ) -) - -recorder.onSegment = { segment in - // Enqueue upload of segment.url. -} - -try recorder.start() -try recorder.waitUntilFirstFrame() -// ... -let manifest = try recorder.stop() -``` - -`session.json` contains the precise wall-clock time for video PTS zero. In -segmented mode it also contains the init segment and ordered media segment -file/duration entries; in single-file mode it points at `recording.mp4`. diff --git a/packages/build-tools/resources/record-sim/Sources/RecordSim/FramebufferDisplaySource.swift b/packages/build-tools/resources/record-sim/Sources/RecordSim/FramebufferDisplaySource.swift deleted file mode 100644 index 134416427f..0000000000 --- a/packages/build-tools/resources/record-sim/Sources/RecordSim/FramebufferDisplaySource.swift +++ /dev/null @@ -1,192 +0,0 @@ -import Foundation -import IOSurface -import ObjectiveC - -final class FramebufferDisplaySource { - struct SurfaceSnapshot { - let surface: IOSurface - let width: Int - let height: Int - let seed: UInt32 - } - - private let deviceUDID: String - private let callbackQueue: DispatchQueue - private let onFrame: () -> Void - private let onSurfaceChange: () -> Void - private var descriptors: [NSObject] = [] - private var callbackUUIDs: [ObjectIdentifier: NSUUID] = [:] - private var retainedBlocks: [AnyObject] = [] - private var ioClient: NSObject? - - init( - deviceUDID: String, - callbackQueue: DispatchQueue, - onFrame: @escaping () -> Void, - onSurfaceChange: @escaping () -> Void - ) { - self.deviceUDID = deviceUDID - self.callbackQueue = callbackQueue - self.onFrame = onFrame - self.onSurfaceChange = onSurfaceChange - } - - func start() throws { - try PrivateSimulatorFrameworks.load() - guard let device = SimulatorDeviceLookup.find(udid: deviceUDID) else { - throw RecorderError.make(1, "Simulator \(deviceUDID) not found") - } - let state = device.value(forKey: "stateString") as? String ?? "unknown" - guard state == "Booted" else { - throw RecorderError.make(2, "Simulator \(deviceUDID) is not booted (state: \(state))") - } - guard - let io = device.perform(NSSelectorFromString("io"))?.takeUnretainedValue() as? NSObject - else { - throw RecorderError.make(3, "Failed to get simulator IO client") - } - ioClient = io - try wireUpFramebuffer() - } - - func stop() { - unregisterCallbacks() - descriptors.removeAll() - retainedBlocks.removeAll() - ioClient = nil - } - - func surfaceSnapshot() -> SurfaceSnapshot? { - let surfaceSelector = NSSelectorFromString("framebufferSurface") - var bestSnapshot: SurfaceSnapshot? - var bestArea = 0 - for descriptor in descriptors { - guard let surfaceObject = descriptor.perform(surfaceSelector)?.takeUnretainedValue() - else { - continue - } - let surface = unsafeBitCast(surfaceObject, to: IOSurface.self) - let width = IOSurfaceGetWidth(surface) - let height = IOSurfaceGetHeight(surface) - let area = width * height - if area > bestArea { - bestSnapshot = SurfaceSnapshot( - surface: surface, - width: width, - height: height, - seed: IOSurfaceGetSeed(surface) - ) - bestArea = area - } - } - return bestSnapshot - } - - func rewireFramebuffer() throws { - try wireUpFramebuffer() - } - - private func wireUpFramebuffer() throws { - guard let io = ioClient else { - throw RecorderError.make(3, "No simulator IO client") - } - io.perform(NSSelectorFromString("updateIOPorts")) - let nextDescriptors = try findFramebufferDescriptors(io: io) - unregisterCallbacks() - descriptors = nextDescriptors - retainedBlocks.removeAll() - do { - for descriptor in descriptors { - try registerCallbacks(descriptor: descriptor) - } - } catch { - unregisterCallbacks() - descriptors.removeAll() - retainedBlocks.removeAll() - throw error - } - } - - private func findFramebufferDescriptors(io: NSObject) throws -> [NSObject] { - guard let ports = io.value(forKey: "deviceIOPorts") as? [NSObject] else { - throw RecorderError.make(4, "Failed to read simulator IO ports") - } - let portIdentifierSelector = NSSelectorFromString("portIdentifier") - let descriptorSelector = NSSelectorFromString("descriptor") - let surfaceSelector = NSSelectorFromString("framebufferSurface") - - var candidates: [NSObject] = [] - for port in ports { - guard port.responds(to: portIdentifierSelector), - let portIdentifier = port.perform(portIdentifierSelector)?.takeUnretainedValue(), - "\(portIdentifier)" == "com.apple.framebuffer.display", - port.responds(to: descriptorSelector), - let descriptor = port.perform(descriptorSelector)?.takeUnretainedValue() - as? NSObject, - descriptor.responds(to: surfaceSelector) - else { - continue - } - candidates.append(descriptor) - } - if candidates.isEmpty { - throw RecorderError.make(5, "No framebuffer display descriptor found") - } - return candidates - } - - private func registerCallbacks(descriptor: NSObject) throws { - let selector = NSSelectorFromString( - "registerScreenCallbacksWithUUID:callbackQueue:frameCallback:surfacesChangedCallback:propertiesChangedCallback:" - ) - guard descriptor.responds(to: selector) else { - throw RecorderError.make(6, "Framebuffer descriptor does not support screen callbacks") - } - guard let msgSendPointer = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "objc_msgSend") - else { - throw RecorderError.make(7, "objc_msgSend not found") - } - typealias MsgSend = - @convention(c) ( - AnyObject, Selector, AnyObject, AnyObject, AnyObject, AnyObject, AnyObject - ) -> Void - let msgSend = unsafeBitCast(msgSendPointer, to: MsgSend.self) - - let uuid = NSUUID() - callbackUUIDs[ObjectIdentifier(descriptor)] = uuid - - let frameCallback: @convention(block) () -> Void = { [weak self] in - self?.onFrame() - } - let surfacesCallback: @convention(block) (AnyObject?, AnyObject?) -> Void = { - [weak self] _, _ in - self?.onSurfaceChange() - } - let propertiesCallback: @convention(block) () -> Void = {} - retainedBlocks.append(frameCallback as AnyObject) - retainedBlocks.append(surfacesCallback as AnyObject) - retainedBlocks.append(propertiesCallback as AnyObject) - - msgSend( - descriptor, - selector, - uuid, - callbackQueue as AnyObject, - frameCallback as AnyObject, - surfacesCallback as AnyObject, - propertiesCallback as AnyObject - ) - } - - private func unregisterCallbacks() { - let selector = NSSelectorFromString("unregisterScreenCallbacksWithUUID:") - for descriptor in descriptors { - if let uuid = callbackUUIDs[ObjectIdentifier(descriptor)], - descriptor.responds(to: selector) - { - descriptor.perform(selector, with: uuid) - } - } - callbackUUIDs.removeAll() - } -} diff --git a/packages/build-tools/resources/record-sim/Sources/RecordSim/PrivateSimulatorServices.swift b/packages/build-tools/resources/record-sim/Sources/RecordSim/PrivateSimulatorServices.swift deleted file mode 100644 index 5930d0ca27..0000000000 --- a/packages/build-tools/resources/record-sim/Sources/RecordSim/PrivateSimulatorServices.swift +++ /dev/null @@ -1,96 +0,0 @@ -import Foundation -import ObjectiveC - -enum XcodeDeveloperDirectory { - static let current: String = { - if let developerDir = ProcessInfo.processInfo.environment["DEVELOPER_DIR"], - !developerDir.isEmpty - { - return developerDir - } - - let fallback = "/Applications/Xcode.app/Contents/Developer" - let pipe = Pipe() - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/xcode-select") - process.arguments = ["-p"] - process.standardOutput = pipe - do { - try process.run() - } catch { - return fallback - } - process.waitUntilExit() - return String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? fallback - }() -} - -enum PrivateSimulatorFrameworks { - static func load() throws { - try loadResult.get() - } - - private static let loadResult: Result = { - let developerDir = XcodeDeveloperDirectory.current - let candidates = [ - "/Library/Developer/PrivateFrameworks/CoreSimulator.framework/CoreSimulator", - "\(developerDir)/Library/PrivateFrameworks/CoreSimulator.framework/CoreSimulator", - "\(developerDir)/../SharedFrameworks/SimulatorKit.framework/SimulatorKit", - "\(developerDir)/Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit", - ] - var failures: [String] = [] - for path in candidates { - if dlopen(path, RTLD_NOW) == nil { - let message = dlerror().map { String(cString: $0) } ?? "unknown error" - failures.append("\(path): \(message)") - } - } - guard NSClassFromString("SimServiceContext") != nil else { - let tried = candidates.joined(separator: ", ") - let errors = failures.isEmpty ? "no dlopen errors" : failures.joined(separator: "; ") - return .failure( - RecorderError.make( - 8, - "Failed to load CoreSimulator/SimulatorKit (tried: \(tried); errors: \(errors))" - ) - ) - } - return .success(()) - }() -} - -enum SimulatorDeviceLookup { - private static let context: NSObject? = { - guard let contextClass = NSClassFromString("SimServiceContext") as? NSObject.Type else { - return nil - } - let sharedSelector = NSSelectorFromString("sharedServiceContextForDeveloperDir:error:") - return contextClass.perform( - sharedSelector, with: XcodeDeveloperDirectory.current, with: nil)? - .takeUnretainedValue() as? NSObject - }() - - static func find(udid: String) -> NSObject? { - guard let context else { - return nil - } - let deviceSetSelector = NSSelectorFromString("defaultDeviceSetWithError:") - guard - let deviceSet = context.perform(deviceSetSelector, with: nil)? - .takeUnretainedValue() as? NSObject - else { - return nil - } - guard let devices = deviceSet.value(forKey: "devices") as? [NSObject] else { - return nil - } - return devices.first { - ($0.value(forKey: "UDID") as? NSUUID)?.uuidString == udid - } - } - - static func stateString(udid: String) -> String? { - find(udid: udid)?.value(forKey: "stateString") as? String - } -} diff --git a/packages/build-tools/resources/record-sim/Sources/RecordSim/RecordingModels.swift b/packages/build-tools/resources/record-sim/Sources/RecordSim/RecordingModels.swift deleted file mode 100644 index 809957466b..0000000000 --- a/packages/build-tools/resources/record-sim/Sources/RecordSim/RecordingModels.swift +++ /dev/null @@ -1,77 +0,0 @@ -import Foundation - -public enum RecorderCodec: String, Codable, Sendable { - case h264 - case hevc -} - -public struct SimulatorRecordingConfiguration: Sendable { - public var deviceUDID: String - public var outputDirectory: URL - public var fps: Int - public var bitrate: Int - public var codec: RecorderCodec - public var segmentDuration: TimeInterval - public var maxPendingFrames: Int - public var overwrite: Bool - - public init( - deviceUDID: String, - outputDirectory: URL, - fps: Int = 30, - bitrate: Int = 30_000_000, - codec: RecorderCodec = .h264, - segmentDuration: TimeInterval = 120, - maxPendingFrames: Int = 8, - overwrite: Bool = true - ) { - self.deviceUDID = deviceUDID - self.outputDirectory = outputDirectory - self.fps = fps - self.bitrate = bitrate - self.codec = codec - self.segmentDuration = segmentDuration - self.maxPendingFrames = maxPendingFrames - self.overwrite = overwrite - } -} - -public enum SegmentKind: String, Codable, Sendable { - case initialization - case media -} - -public struct SegmentOutput: Sendable { - public let kind: SegmentKind - public let relativePath: String - public let url: URL - public let byteCount: Int - public let durationSeconds: Double? -} - -public struct MediaSegmentRecord: Codable, Sendable { - public let file: String - public let durationSeconds: Double -} - -public struct FirstFrameWallClock: Codable, Sendable { - public let unixMs: Int64 - public let iso8601: String -} - -public enum RecordingFinalizationStage: Sendable { - case captureStopped - case videoSaved -} - -public struct RecordingManifest: Codable, Sendable { - public let firstFrameWallClock: FirstFrameWallClock - public let width: Int - public let height: Int - public let hlsVersion: Int? - public let hlsTargetDurationSeconds: Int? - public let hlsMediaSequence: Int? - public let recording: String? - public let initSegment: String? - public let segments: [MediaSegmentRecord] -} diff --git a/packages/build-tools/resources/record-sim/Sources/RecordSim/RecordingOutputWriter.swift b/packages/build-tools/resources/record-sim/Sources/RecordSim/RecordingOutputWriter.swift deleted file mode 100644 index 6b03fb9694..0000000000 --- a/packages/build-tools/resources/record-sim/Sources/RecordSim/RecordingOutputWriter.swift +++ /dev/null @@ -1,140 +0,0 @@ -import AVFoundation -import Foundation - -final class RecordingOutputWriter: NSObject, AVAssetWriterDelegate { - private let rootDirectory: URL - private let segmentsDirectory: URL - let singleRecordingURL: URL - private let stateQueue = DispatchQueue(label: "record-sim.segment-writer") - private let notificationQueue = DispatchQueue(label: "record-sim.segment-notifications") - private let onSegment: ((SegmentOutput) -> Void)? - private var initSegmentPath: String? - private var segments: [MediaSegmentRecord] = [] - private var nextMediaSegmentIndex = 0 - private var firstError: Error? - - init(rootDirectory: URL, onSegment: ((SegmentOutput) -> Void)?) { - self.rootDirectory = rootDirectory - self.segmentsDirectory = rootDirectory.appendingPathComponent("segments", isDirectory: true) - self.singleRecordingURL = rootDirectory.appendingPathComponent("recording.mp4") - self.onSegment = onSegment - super.init() - } - - func prepare(overwrite: Bool, segmented: Bool) throws { - if overwrite { - try? FileManager.default.removeItem(at: rootDirectory) - } - try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true) - if segmented { - try FileManager.default.createDirectory(at: segmentsDirectory, withIntermediateDirectories: true) - } - } - - var error: Error? { - stateQueue.sync { firstError } - } - - func assetWriter( - _ writer: AVAssetWriter, - didOutputSegmentData segmentData: Data, - segmentType: AVAssetSegmentType, - segmentReport: AVAssetSegmentReport? - ) { - var notification: SegmentOutput? - - stateQueue.sync { - if firstError != nil { - return - } - do { - switch segmentType { - case .initialization: - let relativePath = "init.mp4" - let url = rootDirectory.appendingPathComponent(relativePath) - try segmentData.write(to: url, options: .atomic) - initSegmentPath = relativePath - notification = SegmentOutput( - kind: .initialization, - relativePath: relativePath, - url: url, - byteCount: segmentData.count, - durationSeconds: nil - ) - case .separable: - let index = nextMediaSegmentIndex - let relativePath = String(format: "segments/segment-%06d.m4s", index) - let url = rootDirectory.appendingPathComponent(relativePath) - try segmentData.write(to: url, options: .atomic) - - let videoReport = segmentReport?.trackReports.first(where: { $0.mediaType == .video }) - let duration = videoReport.flatMap { timeSeconds($0.duration) } ?? 0 - let record = MediaSegmentRecord( - file: relativePath, - durationSeconds: duration - ) - nextMediaSegmentIndex += 1 - segments.append(record) - notification = SegmentOutput( - kind: .media, - relativePath: relativePath, - url: url, - byteCount: segmentData.count, - durationSeconds: duration - ) - default: - break - } - } catch { - firstError = error - } - } - - if let notification, let onSegment { - notificationQueue.async { - onSegment(notification) - } - } - } - - @discardableResult - func writeManifest( - configuration: SimulatorRecordingConfiguration, - firstFrameWallClock: Date, - width: Int, - height: Int - ) throws -> RecordingManifest { - try stateQueue.sync { - if let firstError { - throw firstError - } - let segmented = configuration.segmentDuration > 0 - if segmented, initSegmentPath == nil { - throw RecorderError.make(50, "Missing initialization segment") - } - if segmented, segments.isEmpty { - throw RecorderError.make(51, "No media segments were written") - } - let targetDuration = segmented - ? max(1, Int(ceil(segments.map(\.durationSeconds).max() ?? configuration.segmentDuration))) - : nil - let manifest = RecordingManifest( - firstFrameWallClock: FirstFrameWallClock( - unixMs: unixMs(firstFrameWallClock), - iso8601: iso8601(firstFrameWallClock) - ), - width: width, - height: height, - hlsVersion: segmented ? 7 : nil, - hlsTargetDurationSeconds: targetDuration, - hlsMediaSequence: segmented ? 0 : nil, - recording: segmented ? nil : "recording.mp4", - initSegment: segmented ? initSegmentPath : nil, - segments: segments - ) - let data = try JSONEncoder.prettySorted.encode(manifest) - try data.write(to: rootDirectory.appendingPathComponent("session.json"), options: .atomic) - return manifest - } - } -} diff --git a/packages/build-tools/resources/record-sim/Sources/RecordSim/SimulatorRecorder.swift b/packages/build-tools/resources/record-sim/Sources/RecordSim/SimulatorRecorder.swift deleted file mode 100644 index 1eca904600..0000000000 --- a/packages/build-tools/resources/record-sim/Sources/RecordSim/SimulatorRecorder.swift +++ /dev/null @@ -1,644 +0,0 @@ -import AVFoundation -import CoreVideo -import Foundation -import IOSurface -import UniformTypeIdentifiers - -public final class SimulatorRecorder { - public var onSegment: ((SegmentOutput) -> Void)? - public var onSimulatorStopped: ((String) -> Void)? - public var onFinalizationStage: ((RecordingFinalizationStage) -> Void)? - - private static let callbackStalenessTimeout: TimeInterval = 5 - private static let firstFrameRewireInterval: TimeInterval = 1 - private let configuration: SimulatorRecordingConfiguration - private let callbackQueue = DispatchQueue( - label: "record-sim.frame-callbacks", qos: .userInteractive) - private let writerQueue = DispatchQueue(label: "record-sim.asset-writer", qos: .userInitiated) - private let eventQueue = DispatchQueue(label: "record-sim.events", qos: .utility) - private let pendingLock = NSLock() - private var pendingFrames = 0 - private var displaySource: FramebufferDisplaySource? - private var outputWriter: RecordingOutputWriter? - private var writer: AVAssetWriter? - private var input: AVAssetWriterInput? - private var adaptor: AVAssetWriterInputPixelBufferAdaptor? - private var boundaryTimer: DispatchSourceTimer? - private var monotonicClock = MonotonicClock() - private var firstAcceptedCaptureTime: CMTime? - private var firstAcceptedWallClock: Date? - private var recordingWidth: Int? - private var recordingHeight: Int? - private var lastPTS: CMTime? - private var lastSeed: UInt32? - private var lastFrameCallbackElapsed: TimeInterval? - private var lastFirstFrameRewireElapsed: TimeInterval = 0 - private var lastAppendedPixelBuffer: CVPixelBuffer? - private var nextBoundaryElapsed: TimeInterval = 0 - private var simulatorStoppedReason: String? - private var stopped = false - private var firstFrameReady = false - private let firstFrameSemaphore = DispatchSemaphore(value: 0) - private var firstError: Error? - - public init(configuration: SimulatorRecordingConfiguration) { - self.configuration = configuration - } - - public func start() throws { - try validateConfiguration() - - let outputWriter = RecordingOutputWriter( - rootDirectory: configuration.outputDirectory, - onSegment: { [weak self] segment in - self?.onSegment?(segment) - }) - try outputWriter.prepare( - overwrite: configuration.overwrite, segmented: configuration.segmentDuration > 0) - self.outputWriter = outputWriter - - monotonicClock = MonotonicClock() - nextBoundaryElapsed = configuration.segmentDuration - lastFirstFrameRewireElapsed = 0 - stopped = false - - let source = FramebufferDisplaySource( - deviceUDID: configuration.deviceUDID, - callbackQueue: callbackQueue, - onFrame: { [weak self] in - self?.captureFrame(force: false, reason: .callback) - }, - onSurfaceChange: { [weak self] in - self?.captureFrame(force: true, reason: .surfaceChange) - } - ) - try source.start() - displaySource = source - - startBoundaryTimer() - } - - public func waitUntilFirstFrame(timeoutSeconds: TimeInterval = 15) throws { - let alreadyReady = try writerQueue.sync { - if let firstError { - throw firstError - } - return firstFrameReady - } - if alreadyReady { - return - } - if firstFrameSemaphore.wait(timeout: .now() + timeoutSeconds) == .timedOut { - throw RecorderError.make(20, "Timed out waiting for first frame") - } - try writerQueue.sync { - if let firstError { - throw firstError - } - if !firstFrameReady { - throw RecorderError.make(21, "Recorder stopped before first frame") - } - } - } - - public func simulatorStopReason() -> String? { - callbackQueue.sync { - simulatorStoppedReason - } - } - - @discardableResult - public func stop() throws -> RecordingManifest { - callbackQueue.sync { - stopped = true - boundaryTimer?.cancel() - boundaryTimer = nil - displaySource?.stop() - displaySource = nil - } - onFinalizationStage?(.captureStopped) - - return try writerQueue.sync { - if let firstError { - throw firstError - } - guard let writer, let input, let outputWriter else { - let error = RecorderError.make(22, "No frames were captured") - firstError = error - signalFirstFrameReadyIfNeeded() - throw error - } - try appendTailFrameIfNeeded(finalPTS: monotonicClock.elapsedTime()) - input.markAsFinished() - let semaphore = DispatchSemaphore(value: 0) - writer.finishWriting { - semaphore.signal() - } - if semaphore.wait(timeout: .now() + 60) == .timedOut { - throw RecorderError.make(23, "Timed out finishing AVAssetWriter") - } - if writer.status == .failed { - throw writer.error ?? RecorderError.make(24, "AVAssetWriter failed") - } - if let error = outputWriter.error { - throw error - } - onFinalizationStage?(.videoSaved) - guard let firstAcceptedWallClock else { - throw RecorderError.make(25, "Missing first frame wall-clock timestamp") - } - guard let recordingWidth, let recordingHeight else { - throw RecorderError.make(27, "Missing recording dimensions") - } - return try outputWriter.writeManifest( - configuration: configuration, - firstFrameWallClock: firstAcceptedWallClock, - width: recordingWidth, - height: recordingHeight - ) - } - } - - private enum CaptureReason { - case callback - case surfaceChange - case boundary - case healthProbe - } - - private func validateConfiguration() throws { - guard !configuration.deviceUDID.isEmpty else { - throw RecorderError.make(30, "deviceUDID must not be empty") - } - guard configuration.fps > 0, configuration.fps <= 120 else { - throw RecorderError.make(31, "fps must be between 1 and 120") - } - guard configuration.bitrate > 0 else { - throw RecorderError.make(32, "bitrate must be positive") - } - guard configuration.segmentDuration >= 0 else { - throw RecorderError.make(33, "segmentDuration must be non-negative") - } - guard configuration.maxPendingFrames > 0 else { - throw RecorderError.make(34, "maxPendingFrames must be positive") - } - } - - private func startBoundaryTimer() { - let timer = DispatchSource.makeTimerSource(queue: callbackQueue) - timer.schedule(deadline: .now() + 1, repeating: 1, leeway: .milliseconds(100)) - timer.setEventHandler { [weak self] in - self?.probeSimulatorState() - self?.probeFramebufferHealth() - self?.appendBoundaryFrameIfNeeded() - } - timer.resume() - boundaryTimer = timer - } - - private func appendBoundaryFrameIfNeeded() { - if stopped { - return - } - guard configuration.segmentDuration > 0 else { - return - } - writerQueue.sync { - guard firstAcceptedCaptureTime != nil else { - return - } - let currentPTSSeconds = CMTimeGetSeconds( - normalizedPresentationTime(for: monotonicClock.elapsedTime())) - guard currentPTSSeconds >= nextBoundaryElapsed else { - return - } - let boundary = nextBoundaryElapsed - guard let lastPTS else { - return - } - - do { - if CMTimeGetSeconds(lastPTS) < boundary { - let boundaryPTS = CMTime(seconds: boundary, preferredTimescale: 1_000_000_000) - guard try appendHeldFrame(at: boundaryPTS) else { - return - } - } - nextBoundaryElapsed = boundary + configuration.segmentDuration - } catch { - firstError = error - signalFirstFrameReadyIfNeeded() - } - } - } - - private func captureFrame(force: Bool, reason: CaptureReason) { - if stopped { - return - } - if reason == .callback { - lastFrameCallbackElapsed = monotonicClock.elapsedSeconds() - } - guard let snapshot = displaySource?.surfaceSnapshot() else { - return - } - let surface = snapshot.surface - let surfaceWidth = snapshot.width - let surfaceHeight = snapshot.height - guard surfaceWidth > 0, surfaceHeight > 0 else { - return - } - - let seed = snapshot.seed - let matchesLastAppendedSeed = writerQueue.sync { lastSeed == seed } - if !force, matchesLastAppendedSeed { - return - } - - guard reservePendingFrame() else { - return - } - - let capturedAt = monotonicClock.elapsedTime() - let capturedAtWallClock = Date() - do { - let pixelBuffer = try copySurfaceToOwnedPixelBuffer( - surface, - width: surfaceWidth, - height: surfaceHeight - ) - appendOwned( - pixelBuffer: pixelBuffer, - seed: seed, - capturedAt: capturedAt, - capturedAtWallClock: capturedAtWallClock - ) - } catch { - releasePendingFrame() - writerQueue.async { - self.firstError = error - self.signalFirstFrameReadyIfNeeded() - } - } - } - - private func probeFramebufferHealth() { - guard !stopped, let displaySource else { - return - } - let elapsed = monotonicClock.elapsedSeconds() - if !isFirstFrameReady(), - elapsed - lastFirstFrameRewireElapsed >= Self.firstFrameRewireInterval - { - lastFirstFrameRewireElapsed = elapsed - rewireFramebuffer() - captureFrame(force: true, reason: .healthProbe) - return - } - - guard let snapshot = displaySource.surfaceSnapshot(), - snapshot.width > 0, - snapshot.height > 0 - else { - rewireFramebuffer() - return - } - - let observedSeed = snapshot.seed - let lastAppendedSeed = writerQueue.sync { lastSeed } - guard let lastAppendedSeed, observedSeed != lastAppendedSeed else { - return - } - - let lastCallback = lastFrameCallbackElapsed ?? 0 - if elapsed - lastCallback >= Self.callbackStalenessTimeout { - captureFrame(force: false, reason: .healthProbe) - rewireFramebuffer() - } - } - - private func probeSimulatorState() { - guard !stopped, simulatorStoppedReason == nil else { - return - } - let state = SimulatorDeviceLookup.stateString(udid: configuration.deviceUDID) ?? "missing" - guard state != "Booted" else { - return - } - simulatorStoppedReason = state - let error = RecorderError.make(26, "Simulator stopped before first frame (state: \(state))") - writerQueue.async { - if self.firstError == nil, !self.firstFrameReady { - self.firstError = error - self.signalFirstFrameReadyIfNeeded() - } - } - let onSimulatorStopped = onSimulatorStopped - eventQueue.async { - onSimulatorStopped?(state) - } - } - - private func rewireFramebuffer() { - guard let displaySource else { - return - } - do { - try displaySource.rewireFramebuffer() - } catch { - writerQueue.async { - self.firstError = error - self.signalFirstFrameReadyIfNeeded() - } - } - } - - private func reservePendingFrame() -> Bool { - pendingLock.lock() - defer { pendingLock.unlock() } - if pendingFrames >= configuration.maxPendingFrames { - return false - } - pendingFrames += 1 - return true - } - - private func releasePendingFrame() { - pendingLock.lock() - pendingFrames = max(0, pendingFrames - 1) - pendingLock.unlock() - } - - private func copySurfaceToOwnedPixelBuffer( - _ surface: IOSurface, - width: Int, - height: Int - ) throws -> CVPixelBuffer { - let pool = try writerQueue.sync { - if let firstError { - throw firstError - } - if writer == nil { - try startWriter(width: width, height: height) - } - guard let pool = adaptor?.pixelBufferPool else { - throw RecorderError.make(40, "AVAssetWriter pixel buffer pool is unavailable") - } - return pool - } - - var output: CVPixelBuffer? - let status = CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &output) - guard status == kCVReturnSuccess, let destination = output else { - throw RecorderError.make(41, "Failed to allocate pixel buffer from pool: \(status)") - } - - let destinationLockStatus = CVPixelBufferLockBaseAddress(destination, []) - guard destinationLockStatus == kCVReturnSuccess else { - throw RecorderError.make(50, "Failed to lock pixel buffer: \(destinationLockStatus)") - } - let surfaceLockStatus = IOSurfaceLock(surface, .readOnly, nil) - guard surfaceLockStatus == KERN_SUCCESS else { - CVPixelBufferUnlockBaseAddress(destination, []) - throw RecorderError.make(49, "Failed to lock IOSurface: \(surfaceLockStatus)") - } - defer { - IOSurfaceUnlock(surface, .readOnly, nil) - CVPixelBufferUnlockBaseAddress(destination, []) - } - - guard let destinationAddress = CVPixelBufferGetBaseAddress(destination) else { - throw RecorderError.make(42, "Pixel buffer has no base address") - } - let sourceAddress = IOSurfaceGetBaseAddress(surface) - let sourceStride = IOSurfaceGetBytesPerRow(surface) - let destinationStride = CVPixelBufferGetBytesPerRow(destination) - let copyWidth = min(width, IOSurfaceGetWidth(surface), CVPixelBufferGetWidth(destination)) - let copyHeight = min( - height, IOSurfaceGetHeight(surface), CVPixelBufferGetHeight(destination)) - let copyBytes = min(copyWidth * 4, sourceStride, destinationStride) - for row in 0..= deadline { - throw RecorderError.make(45, "Timed out waiting to append tail frame") - } - Thread.sleep(forTimeInterval: 0.05) - } - } - - private func appendTailFrame(finalPTS: CMTime) throws -> Bool { - let pts = normalizedPresentationTime(for: finalPTS) - return try appendHeldFrame(at: pts) - } - - private func appendPendingBoundaryFrames(before pts: CMTime) throws -> Bool { - guard configuration.segmentDuration > 0, - firstAcceptedCaptureTime != nil - else { - return true - } - - while CMTimeGetSeconds(pts) > nextBoundaryElapsed { - if let lastPTS, CMTimeGetSeconds(lastPTS) >= nextBoundaryElapsed { - nextBoundaryElapsed += configuration.segmentDuration - continue - } - - let boundaryPTS = CMTime( - seconds: nextBoundaryElapsed, preferredTimescale: 1_000_000_000) - guard try appendHeldFrame(at: boundaryPTS) else { - return false - } - nextBoundaryElapsed += configuration.segmentDuration - } - return true - } - - private func appendHeldFrame(at pts: CMTime) throws -> Bool { - guard let input, - let adaptor, - let lastPTS, - let lastAppendedPixelBuffer - else { - return true - } - if CMTimeCompare(pts, lastPTS) <= 0 { - return true - } - guard input.isReadyForMoreMediaData else { - return false - } - if !adaptor.append(lastAppendedPixelBuffer, withPresentationTime: pts) { - throw writer?.error ?? RecorderError.make(45, "Failed to append held frame") - } - self.lastPTS = pts - return true - } - - private func normalizedPresentationTime(for capturedAt: CMTime) -> CMTime { - let start = firstAcceptedCaptureTime ?? capturedAt - return CMTimeSubtract(capturedAt, start) - } - - private func startWriter(width: Int, height: Int) throws { - guard let outputWriter else { - throw RecorderError.make(46, "Missing output writer") - } - - let writer: AVAssetWriter - if configuration.segmentDuration > 0 { - writer = AVAssetWriter(contentType: .mpeg4Movie) - writer.delegate = outputWriter - writer.outputFileTypeProfile = .mpeg4AppleHLS - writer.preferredOutputSegmentInterval = CMTime( - seconds: configuration.segmentDuration, - preferredTimescale: 600 - ) - } else { - writer = try AVAssetWriter(outputURL: outputWriter.singleRecordingURL, fileType: .mp4) - } - writer.initialSegmentStartTime = .zero - - let input = AVAssetWriterInput( - mediaType: .video, - outputSettings: assetWriterSettings(width: width, height: height) - ) - input.expectsMediaDataInRealTime = true - - let attributes: [String: Any] = [ - kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, - kCVPixelBufferWidthKey as String: width, - kCVPixelBufferHeightKey as String: height, - kCVPixelBufferIOSurfacePropertiesKey as String: [:], - ] - let adaptor = AVAssetWriterInputPixelBufferAdaptor( - assetWriterInput: input, - sourcePixelBufferAttributes: attributes - ) - - guard writer.canAdd(input) else { - throw RecorderError.make(47, "AVAssetWriter cannot add video input") - } - writer.add(input) - guard writer.startWriting() else { - throw writer.error ?? RecorderError.make(48, "AVAssetWriter failed to start") - } - writer.startSession(atSourceTime: .zero) - - self.writer = writer - self.input = input - self.adaptor = adaptor - recordingWidth = width - recordingHeight = height - } - - private func assetWriterSettings(width: Int, height: Int) -> [String: Any] { - let codecType: AVVideoCodecType = configuration.codec == .hevc ? .hevc : .h264 - var compression: [String: Any] = [ - AVVideoAverageBitRateKey: configuration.bitrate, - AVVideoExpectedSourceFrameRateKey: configuration.fps, - AVVideoAllowFrameReorderingKey: true, - ] - if configuration.segmentDuration > 0 { - compression[AVVideoMaxKeyFrameIntervalKey] = max( - 1, - Int((configuration.segmentDuration * Double(configuration.fps)).rounded()) - ) - } - if configuration.codec == .h264 { - compression[AVVideoProfileLevelKey] = AVVideoProfileLevelH264HighAutoLevel - } - return [ - AVVideoCodecKey: codecType, - AVVideoWidthKey: width, - AVVideoHeightKey: height, - AVVideoCompressionPropertiesKey: compression, - ] - } - - private func signalFirstFrameReadyIfNeeded() { - if firstFrameReady { - return - } - firstFrameReady = true - firstFrameSemaphore.signal() - } - - private func isFirstFrameReady() -> Bool { - writerQueue.sync { - firstFrameReady - } - } -} diff --git a/packages/build-tools/resources/record-sim/Sources/RecordSim/Utilities.swift b/packages/build-tools/resources/record-sim/Sources/RecordSim/Utilities.swift deleted file mode 100644 index 1c2f64cc54..0000000000 --- a/packages/build-tools/resources/record-sim/Sources/RecordSim/Utilities.swift +++ /dev/null @@ -1,60 +0,0 @@ -import CoreMedia -import Foundation - -enum RecorderError { - static func make(_ code: Int, _ message: String) -> NSError { - NSError( - domain: "RecordSim", - code: code, - userInfo: [NSLocalizedDescriptionKey: message] - ) - } -} - -extension JSONEncoder { - static var prettySorted: JSONEncoder { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - return encoder - } -} - -func timeSeconds(_ time: CMTime) -> Double? { - guard time.isValid, !time.isIndefinite, !time.isNegativeInfinity, !time.isPositiveInfinity else { - return nil - } - return CMTimeGetSeconds(time) -} - -func iso8601(_ date: Date) -> String { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - formatter.timeZone = TimeZone(secondsFromGMT: 0) - return formatter.string(from: date) -} - -func unixMs(_ date: Date) -> Int64 { - Int64((date.timeIntervalSince1970 * 1000).rounded()) -} - -struct MonotonicClock { - private static let timescale: CMTimeScale = 1_000_000_000 - - let startedAtUptimeNanoseconds: UInt64 - - init() { - startedAtUptimeNanoseconds = DispatchTime.now().uptimeNanoseconds - } - - func elapsedSeconds() -> TimeInterval { - TimeInterval(elapsedNanoseconds()) / TimeInterval(Self.timescale) - } - - func elapsedTime() -> CMTime { - CMTime(value: CMTimeValue(elapsedNanoseconds()), timescale: Self.timescale) - } - - private func elapsedNanoseconds() -> UInt64 { - DispatchTime.now().uptimeNanoseconds - startedAtUptimeNanoseconds - } -} diff --git a/packages/build-tools/resources/record-sim/Sources/record-sim/main.swift b/packages/build-tools/resources/record-sim/Sources/record-sim/main.swift deleted file mode 100644 index e368296fe5..0000000000 --- a/packages/build-tools/resources/record-sim/Sources/record-sim/main.swift +++ /dev/null @@ -1,159 +0,0 @@ -import Foundation -import RecordSim - -struct CLIOptions { - var udid: String? - var output: String? - var segmentDuration: TimeInterval = 120 - var fps = 30 - var bitrate = 30_000_000 - var codec: RecorderCodec = .h264 -} - -private var requestedStopSignal: CInt = 0 -private var simulatorStopped: CInt = 0 - -private func handleStopSignal(_ signal: CInt) { - requestedStopSignal = signal -} - -private func isStopRequested() -> Bool { - requestedStopSignal != 0 || simulatorStopped != 0 -} - -func usage() -> String { - """ - Usage: - record-sim --udid --output [options] - - Options: - --segment-duration Target fMP4 media segment duration. Use 0 for one MP4. Default: 120 - --fps Expected source frame rate. Default: 30 - --bitrate AVAssetWriter average bitrate. Default: 30000000 - --codec Video codec. Default: h264 - -h, --help Show this help - """ -} - -func parseOptions(_ args: [String]) throws -> CLIOptions { - var options = CLIOptions() - var i = 0 - - func next(_ flag: String) throws -> String { - i += 1 - guard i < args.count else { - throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing value for \(flag)"]) - } - return args[i] - } - - while i < args.count { - let arg = args[i] - switch arg { - case "--udid", "-u": - options.udid = try next(arg) - case "--output", "--output-dir", "--out", "-o": - options.output = try next(arg) - case "--segment-duration": - options.segmentDuration = Double(try next(arg)) ?? -1 - case "--fps": - options.fps = Int(try next(arg)) ?? 0 - case "--bitrate": - options.bitrate = Int(try next(arg)) ?? 0 - case "--codec": - let raw = try next(arg) - guard let codec = RecorderCodec(rawValue: raw) else { - throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--codec must be h264 or hevc"]) - } - options.codec = codec - case "--help", "-h": - print(usage()) - exit(0) - default: - throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Unknown argument: \(arg)"]) - } - i += 1 - } - - guard options.udid != nil else { - throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing required --udid"]) - } - guard options.output != nil else { - throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing required --output"]) - } - guard options.segmentDuration >= 0 else { - throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--segment-duration must be non-negative"]) - } - guard options.fps > 0, options.fps <= 120 else { - throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--fps must be between 1 and 120"]) - } - guard options.bitrate > 0 else { - throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--bitrate must be positive"]) - } - return options -} - -do { - signal(SIGINT, handleStopSignal) - signal(SIGTERM, handleStopSignal) - - let options = try parseOptions(Array(CommandLine.arguments.dropFirst())) - let configuration = SimulatorRecordingConfiguration( - deviceUDID: options.udid!, - outputDirectory: URL(fileURLWithPath: options.output!), - fps: options.fps, - bitrate: options.bitrate, - codec: options.codec, - segmentDuration: options.segmentDuration - ) - - let recorder = SimulatorRecorder(configuration: configuration) - recorder.onSimulatorStopped = { state in - simulatorStopped = 1 - fputs("[record-sim] simulator stopped state=\(state); finalizing recording\n", stderr) - } - recorder.onFinalizationStage = { stage in - switch stage { - case .captureStopped: - fputs("[record-sim] screen capture stopped; saving final video\n", stderr) - case .videoSaved: - fputs("[record-sim] final video saved; writing recording details\n", stderr) - } - } - recorder.onSegment = { segment in - switch segment.kind { - case .initialization: - fputs("[record-sim] init \(segment.relativePath) bytes=\(segment.byteCount)\n", stderr) - case .media: - let duration = segment.durationSeconds ?? 0 - fputs(String(format: "[record-sim] segment %@ duration=%.3fs bytes=%d\n", segment.relativePath, duration, segment.byteCount), stderr) - } - } - - try recorder.start() - try recorder.waitUntilFirstFrame() - fputs("[record-sim] recording -> \(options.output!)\n", stderr) - - while !isStopRequested() { - Thread.sleep(forTimeInterval: 0.1) - } - - if requestedStopSignal != 0 { - let signalName = switch requestedStopSignal { - case SIGINT: "SIGINT" - case SIGTERM: "SIGTERM" - default: "signal \(requestedStopSignal)" - } - fputs("[record-sim] received \(signalName); finishing recording\n", stderr) - } - let manifest = try recorder.stop() - if let recording = manifest.recording { - fputs("[record-sim] done recording=\(recording)\n", stderr) - } else { - fputs("[record-sim] done segments=\(manifest.segments.count)\n", stderr) - } - fputs("[record-sim] manifest \(URL(fileURLWithPath: options.output!).appendingPathComponent("session.json").path)\n", stderr) -} catch { - fputs("error: \(error.localizedDescription)\n", stderr) - exit(1) -} diff --git a/packages/build-tools/src/steps/utils/IosSimulatorRecordingUtils.ts b/packages/build-tools/src/steps/utils/IosSimulatorRecordingUtils.ts index b2edd06a6c..3c951556d9 100644 --- a/packages/build-tools/src/steps/utils/IosSimulatorRecordingUtils.ts +++ b/packages/build-tools/src/steps/utils/IosSimulatorRecordingUtils.ts @@ -10,12 +10,20 @@ import { setTimeout } from 'node:timers/promises'; import { Sentry } from '../../sentry'; import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils'; +import { + PackageManager, + resolveConfiguredPackageManager, + resolvePackageExec, +} from '../../utils/packageManager'; +import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder'; const IOS_SIMULATOR_RECORDING_POLL_INTERVAL_MS = 2_000; -const RECORD_SIM_FINISH_TIMEOUT_MS = 70_000; -const RECORD_SIM_FORCE_STOP_TIMEOUT_MS = 5_000; -const RECORD_SIM_MAX_ATTEMPTS_PER_BOOT = 3; -const RECORD_SIM_COMMAND = 'record-sim'; +const SERVE_SIM_START_TIMEOUT_MS = 60_000; +const SERVE_SIM_FINISH_TIMEOUT_MS = 130_000; +const SERVE_SIM_FORCE_STOP_TIMEOUT_MS = 5_000; +const SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS = 30_000; +const SERVE_SIM_RECORDING_STARTED = 'serve-sim:recording-started'; +const serveSimPackageSpecs = new Map(); type IosSimulatorRecording = { id: string; @@ -25,6 +33,7 @@ type IosSimulatorRecording = { outputDirectory: string; startedAt: Date; getOutput: () => string; + hasStarted: () => boolean; }; type ActiveIosSimulatorRecording = IosSimulatorRecording & { @@ -35,11 +44,13 @@ type ActiveIosSimulatorRecording = IosSimulatorRecording & { type IosSimulatorRecordingSession = { env: Env; logger: bunyan; - recordSimCommand: string; recordingsRootDirectory: string; activeRecordings: Map; completedRecordings: IosSimulatorRecording[]; recordingFailureCounts: Map; + recordingRetryAt: Map; + serverTokens: Map; + completedServerTokens: Map; pollingPromise: Promise; abortController: AbortController; }; @@ -47,31 +58,35 @@ type IosSimulatorRecordingSession = { let activeIosSimulatorRecordingSession: IosSimulatorRecordingSession | null = null; export namespace IosSimulatorRecordingUtils { + export function registerServeSimPackage(udid: string, packageSpec: string): void { + serveSimPackageSpecs.set(udid, packageSpec); + } + + export function unregisterServeSimPackage(udid: string, packageSpec: string): void { + if (serveSimPackageSpecs.get(udid) === packageSpec) { + serveSimPackageSpecs.delete(udid); + } + } + export async function startAsync({ env, logger }: { env: Env; logger: bunyan }): Promise { if (activeIosSimulatorRecordingSession) { logger.info('iOS Simulator screen recording polling is already running.'); return; } - const recordSimCommand = await resolveRecordSimCommandAsync({ env }); - if (!recordSimCommand) { - logger.warn( - 'record-sim binary is not available; iOS Simulator screen recordings are disabled.' - ); - return; - } - const recordingsRootDirectory = await mkdtemp( path.join(os.tmpdir(), 'ios-simulator-recordings-') ); const session: IosSimulatorRecordingSession = { env, logger, - recordSimCommand, recordingsRootDirectory, activeRecordings: new Map(), completedRecordings: [], recordingFailureCounts: new Map(), + recordingRetryAt: new Map(), + serverTokens: new Map(), + completedServerTokens: new Map(), pollingPromise: Promise.resolve(), abortController: new AbortController(), }; @@ -105,29 +120,35 @@ export namespace IosSimulatorRecordingUtils { await Promise.all( [...session.activeRecordings.values()].map(async recording => { logger.info(`Stopping screen recording for ${recording.deviceName}.`); - recording.recordingProcess.kill('SIGINT'); + const startState = await waitForRecorderStartAsync(recording, SERVE_SIM_START_TIMEOUT_MS); + if (startState === 'exited') { + return; + } + const finishTimeoutMs = + startState === 'started' ? SERVE_SIM_FINISH_TIMEOUT_MS : SERVE_SIM_FORCE_STOP_TIMEOUT_MS; + signalRecordingProcess(recording.recordingProcess, 'SIGINT'); const finished = await Promise.race([ recording.completionPromise.then(() => true), - setTimeout(RECORD_SIM_FINISH_TIMEOUT_MS, false, { ref: false }), + setTimeout(finishTimeoutMs, false, { ref: false }), ]); if (finished) { return; } - const recordSimOutput = recording.getOutput().trim(); - const finishTimeoutSeconds = Math.round(RECORD_SIM_FINISH_TIMEOUT_MS / 1_000); + const recorderOutput = recording.getOutput().trim(); + const finishTimeoutSeconds = Math.round(finishTimeoutMs / 1_000); logger.warn( - { recordSimOutput }, + { recorderOutput }, `Screen recording for ${recording.deviceName} did not finish within ${finishTimeoutSeconds} seconds and will be stopped.${ - recordSimOutput - ? `\nRecent recorder messages:\n${recordSimOutput}` + recorderOutput + ? `\nRecent recorder messages:\n${recorderOutput}` : '\nNo recorder messages were captured.' }` ); - recording.recordingProcess.kill('SIGKILL'); + signalRecordingProcess(recording.recordingProcess, 'SIGKILL'); const killed = await Promise.race([ recording.completionPromise.then(() => true), - setTimeout(RECORD_SIM_FORCE_STOP_TIMEOUT_MS, false, { ref: false }), + setTimeout(SERVE_SIM_FORCE_STOP_TIMEOUT_MS, false, { ref: false }), ]); if (!killed) { logger.warn( @@ -148,12 +169,31 @@ export namespace IosSimulatorRecordingUtils { const completedRecordings = [...session.completedRecordings].sort( (a, b) => a.startedAt.getTime() - b.startedAt.getTime() ); - return completedRecordings.map(recording => ({ - udid: recording.udid, - deviceName: recording.deviceName, - runtimeDisplayName: recording.runtimeDisplayName, - directory: recording.outputDirectory, - })); + const recordingsWithManifests = await Promise.all( + completedRecordings.map(async recording => { + if ( + await waitForRecordingManifestAsync( + recording.outputDirectory, + SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS + ) + ) { + return recording; + } + logger.warn( + { recorderOutput: recording.getOutput().trim() }, + `Screen recording for ${recording.deviceName} has no manifest; skipping upload.` + ); + return null; + }) + ); + return recordingsWithManifests + .filter(recording => recording !== null) + .map(recording => ({ + udid: recording.udid, + deviceName: recording.deviceName, + runtimeDisplayName: recording.runtimeDisplayName, + directory: recording.outputDirectory, + })); } } @@ -174,18 +214,38 @@ async function pollIosSimulatorRecordingsAsync( break; } listDevicesErrorCount = 0; + const readyServers = new Map( + (await readServeSimServersAsync(SERVE_SIM_STATE_DIR)).flatMap(server => + server.token ? [[server.udid, server.token] as const] : [] + ) + ); const bootedUdids = new Set(bootedDevices.map(device => device.udid)); - for (const udid of session.recordingFailureCounts.keys()) { + for (const udid of session.serverTokens.keys()) { if (!bootedUdids.has(udid)) { session.recordingFailureCounts.delete(udid); + session.recordingRetryAt.delete(udid); + session.serverTokens.delete(udid); + session.completedServerTokens.delete(udid); } } for (const device of bootedDevices) { + const token = readyServers.get(device.udid); + const packageSpec = serveSimPackageSpecs.get(device.udid); + if (!token || !packageSpec) { + continue; + } + if (session.serverTokens.get(device.udid) !== token) { + session.serverTokens.set(device.udid, token); + session.recordingFailureCounts.delete(device.udid); + session.recordingRetryAt.delete(device.udid); + session.completedServerTokens.delete(device.udid); + } if ( + session.completedServerTokens.get(device.udid) === token || session.activeRecordings.has(device.udid) || - (session.recordingFailureCounts.get(device.udid) ?? 0) >= RECORD_SIM_MAX_ATTEMPTS_PER_BOOT + Date.now() < (session.recordingRetryAt.get(device.udid) ?? 0) ) { continue; } @@ -193,6 +253,7 @@ async function pollIosSimulatorRecordingsAsync( udid: device.udid, deviceName: device.name, runtimeDisplayName: device.runtimeDisplayName, + packageSpec, }); } } catch (err) { @@ -227,49 +288,75 @@ async function startIosSimulatorRecordingAsync( udid, deviceName, runtimeDisplayName, + packageSpec, }: { udid: IosSimulatorUuid; deviceName: string; runtimeDisplayName: string; + packageSpec: string; } ): Promise { const startedAt = new Date(); + const serverToken = session.serverTokens.get(udid); const recordingId = randomUUID(); const outputDirectory = path.join(session.recordingsRootDirectory, recordingId); await mkdir(outputDirectory, { recursive: true }); session.logger.info(`Starting screen recording for ${deviceName}.`); + const recorderExec = resolvePackageExec( + resolveConfiguredPackageManager(session.env, PackageManager.NPM), + [packageSpec, 'record-video'] + ); const recordingSpawn = spawn( - session.recordSimCommand, - ['--udid', udid, '--output', outputDirectory, '--segment-duration', '0'], + recorderExec.command, + [...recorderExec.args, '--udid', udid, '--output', outputDirectory], { env: session.env, stdio: ['ignore', 'pipe', 'pipe'], + detached: true, } ); - const getOutput = captureProcessOutput(recordingSpawn.child); + const { getOutput, hasStarted } = captureProcessOutput(recordingSpawn.child); + const saveFinalizedRecordingAsync = async (): Promise => { + if ( + !(await waitForRecordingManifestAsync(outputDirectory, SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS)) + ) { + return false; + } + if (serverToken) { + session.completedServerTokens.set(udid, serverToken); + } + session.completedRecordings.push({ + id: recordingId, + udid, + deviceName, + runtimeDisplayName, + outputDirectory, + startedAt, + getOutput, + hasStarted, + }); + return true; + }; const completionPromise = recordingSpawn - .then(() => undefined) - .catch((err: unknown) => { - session.recordingFailureCounts.set(udid, (session.recordingFailureCounts.get(udid) ?? 0) + 1); + .then(async () => { + if (!(await saveFinalizedRecordingAsync())) { + scheduleRecordingRetry(session, udid); + } + }) + .catch(async (err: unknown) => { const error = err instanceof Error ? err : new Error(String(err)); Sentry.capture('iOS Simulator screen recording process failed', error); session.logger.warn( - { err: error, recordSimOutput: getOutput() }, + { err: error, recorderOutput: getOutput() }, `Screen recording process failed for ${deviceName}.` ); + if (!hasStarted() || !(await saveFinalizedRecordingAsync())) { + scheduleRecordingRetry(session, udid); + } }) .finally(() => { session.activeRecordings.delete(udid); - session.completedRecordings.push({ - id: recordingId, - udid, - deviceName, - runtimeDisplayName, - outputDirectory, - startedAt, - getOutput, - }); }); session.activeRecordings.set(udid, { @@ -282,31 +369,82 @@ async function startIosSimulatorRecordingAsync( completionPromise, startedAt, getOutput, + hasStarted, }); } -async function resolveRecordSimCommandAsync({ env }: { env: Env }): Promise { - try { - await spawn('which', [RECORD_SIM_COMMAND], { env }); - return RECORD_SIM_COMMAND; - } catch {} +async function waitForRecordingManifestAsync( + outputDirectory: string, + timeoutMs: number +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + await access(path.join(outputDirectory, 'session.json')); + return true; + } catch { + await setTimeout(1_000); + } + } + return false; +} + +async function waitForRecorderStartAsync( + recording: ActiveIosSimulatorRecording, + timeoutMs: number +): Promise<'started' | 'exited' | 'timeout'> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (recording.hasStarted()) { + return 'started'; + } + const exited = await Promise.race([ + recording.completionPromise.then(() => true), + setTimeout(100, false), + ]); + if (exited) { + return 'exited'; + } + } + return 'timeout'; +} + +function scheduleRecordingRetry( + session: IosSimulatorRecordingSession, + udid: IosSimulatorUuid +): void { + const failures = (session.recordingFailureCounts.get(udid) ?? 0) + 1; + session.recordingFailureCounts.set(udid, failures); + session.recordingRetryAt.set( + udid, + Date.now() + Math.min(300_000, 25_000 * 2 ** Math.min(failures - 1, 4)) + ); +} - const packagedRecordSimPath = path.join(__dirname, '..', '..', '..', 'bin', RECORD_SIM_COMMAND); - try { - await access(packagedRecordSimPath); - return packagedRecordSimPath; - } catch { - return null; +function signalRecordingProcess(child: ChildProcess, signal: NodeJS.Signals): void { + if (child.pid !== undefined) { + try { + process.kill(-child.pid, signal); + return; + } catch {} } + child.kill(signal); } -function captureProcessOutput(recordingProcess: ChildProcess): () => string { +function captureProcessOutput(recordingProcess: ChildProcess): { + getOutput: () => string; + hasStarted: () => boolean; +} { let output = ''; + let started = false; const appendChunk = (chunk: Buffer | string): void => { - // Keep enough recorder stderr/stdout for diagnostics without retaining unbounded output. - output = `${output}${chunk.toString()}`.slice(-16_384); + output = `${output}${chunk.toString()}`; + if (output.includes(SERVE_SIM_RECORDING_STARTED)) { + started = true; + } + output = output.slice(-16_384); }; recordingProcess.stdout?.on('data', appendChunk); recordingProcess.stderr?.on('data', appendChunk); - return () => output; + return { getOutput: () => output, hasStarted: () => started }; } diff --git a/packages/build-tools/src/steps/utils/__tests__/IosSimulatorRecordingUtils.test.ts b/packages/build-tools/src/steps/utils/__tests__/IosSimulatorRecordingUtils.test.ts new file mode 100644 index 0000000000..f9110d2f5e --- /dev/null +++ b/packages/build-tools/src/steps/utils/__tests__/IosSimulatorRecordingUtils.test.ts @@ -0,0 +1,193 @@ +import { type Env } from '@expo/eas-build-job'; +import { type bunyan } from '@expo/logger'; +import spawn from '@expo/turtle-spawn'; +import { type ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { writeFile } from 'node:fs/promises'; +import { PassThrough } from 'node:stream'; +import { setTimeout } from 'node:timers/promises'; + +import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils'; +import { Sentry } from '../../../sentry'; +import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils'; +import { readServeSimServersAsync } from '../serveSimMetricsRecorder'; + +jest.mock('@expo/turtle-spawn'); +jest.mock('../serveSimMetricsRecorder'); +jest.mock('../../../sentry'); + +const logger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +} as unknown as bunyan; +const env = {} as Env; +const udid = '06B546AC-7B06-4BED-83DB-83603E8EB537'; + +beforeEach(() => { + IosSimulatorRecordingUtils.registerServeSimPackage(udid, '@expo/serve-sim@next'); + jest + .spyOn(IosSimulatorUtils, 'getAvailableDevicesAsync') + .mockResolvedValue([{ udid, name: 'iPhone 17 Pro', runtimeDisplayName: 'iOS 26.4' } as never]); + jest.mocked(readServeSimServersAsync).mockReset(); + jest.mocked(spawn).mockReset(); +}); + +afterEach(async () => { + await IosSimulatorRecordingUtils.finishAsync({ logger }); + IosSimulatorRecordingUtils.unregisterServeSimPackage(udid, '@expo/serve-sim@next'); + jest.restoreAllMocks(); +}); + +async function waitForRecorderSpawnAsync(): Promise { + for (let attempt = 0; attempt < 50; attempt++) { + if (jest.mocked(spawn).mock.calls.length > 0) { + return; + } + await setTimeout(10); + } + throw new Error('serve-sim recorder did not start'); +} + +function mockFailingRecorder(): void { + jest.mocked(spawn).mockImplementation((_command, args) => { + void writeFile(`${args[6]}/session.json`, JSON.stringify({ recording: 'recording.mp4' })); + return Object.assign(Promise.reject(new Error('recorder exited')), { + child: { + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: jest.fn(), + }, + }) as never; + }); +} + +test('waits for a token-gated serve-sim session before starting a recorder', async () => { + jest + .mocked(readServeSimServersAsync) + .mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563' }]); + + await IosSimulatorRecordingUtils.startAsync({ env, logger }); + await IosSimulatorRecordingUtils.finishAsync({ logger }); + + expect(jest.mocked(spawn)).not.toHaveBeenCalled(); +}); + +test('starts serve-sim record-video and returns only a finalized recording', async () => { + jest + .mocked(readServeSimServersAsync) + .mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]); + let resolveProcess: () => void = () => {}; + const stderr = new PassThrough(); + const child = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr, + kill: jest.fn(), + }) as unknown as ChildProcess; + const processPromise = Object.assign( + new Promise(resolve => { + resolveProcess = resolve; + }), + { + child, + } + ); + jest.mocked(spawn).mockReturnValue(processPromise as never); + + await IosSimulatorRecordingUtils.startAsync({ env, logger }); + await waitForRecorderSpawnAsync(); + const [command, args, options] = jest.mocked(spawn).mock.calls[0]; + expect(command).toBe('npx'); + expect(args.slice(0, 5)).toEqual([ + '--yes', + '@expo/serve-sim@next', + 'record-video', + '--udid', + udid, + ]); + expect(args[5]).toBe('--output'); + expect(args).not.toContain('--segment-duration'); + expect(options?.detached).toBe(true); + + const outputDirectory = args[6]; + jest.mocked(child.kill).mockImplementation(() => { + resolveProcess(); + void setTimeout(20).then(() => + writeFile(`${outputDirectory}/session.json`, JSON.stringify({ recording: 'recording.mp4' })) + ); + return true; + }); + const finishing = IosSimulatorRecordingUtils.finishAsync({ logger }); + await setTimeout(20); + expect(child.kill).not.toHaveBeenCalled(); + stderr.write('serve-sim:recording-started\n'); + stderr.write('x'.repeat(17_000)); + const recordings = await finishing; + + expect(child.kill).toHaveBeenCalledWith('SIGINT'); + expect(recordings).toEqual([ + { + udid, + deviceName: 'iPhone 17 Pro', + runtimeDisplayName: 'iOS 26.4', + directory: outputDirectory, + }, + ]); +}); + +test('retries after a failed recorder when the serve-sim session token changes', async () => { + jest + .mocked(readServeSimServersAsync) + .mockResolvedValueOnce([{ udid, url: 'http://127.0.0.1:43563', token: 'old-token' }]) + .mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'new-token' }]); + mockFailingRecorder(); + + await IosSimulatorRecordingUtils.startAsync({ env, logger }); + await waitForRecorderSpawnAsync(); + await setTimeout(2_100); + + expect(spawn).toHaveBeenCalledTimes(2); + expect(Sentry.capture).toHaveBeenCalled(); +}); + +test('retries after the server lease expires without a token change', async () => { + const startTime = Date.now(); + let currentTime = startTime; + jest.spyOn(Date, 'now').mockImplementation(() => currentTime); + jest + .mocked(readServeSimServersAsync) + .mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]); + mockFailingRecorder(); + + await IosSimulatorRecordingUtils.startAsync({ env, logger }); + await waitForRecorderSpawnAsync(); + currentTime += 26_000; + await setTimeout(2_100); + + expect(spawn).toHaveBeenCalledTimes(2); +}); + +test('does not start a second recording for a server token after a late manifest', async () => { + jest + .mocked(readServeSimServersAsync) + .mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]); + jest.mocked(spawn).mockImplementation((_command, args) => { + const child = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: jest.fn(), + }) as unknown as ChildProcess; + void setTimeout(20).then(() => + writeFile(`${args[6]}/session.json`, JSON.stringify({ recording: 'recording.mp4' })) + ); + return Object.assign(Promise.resolve(), { child }) as never; + }); + + await IosSimulatorRecordingUtils.startAsync({ env, logger }); + await waitForRecorderSpawnAsync(); + await setTimeout(2_100); + + expect(spawn).toHaveBeenCalledTimes(1); + expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toHaveLength(1); +}); diff --git a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts index 521edbd4ae..3a38039641 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -13,6 +13,7 @@ import { createGlobalContextMock } from '../../../__tests__/utils/context'; import { CustomBuildContext } from '../../../customBuildContext'; import { Sentry } from '../../../sentry'; import { turtleFetch } from '../../../utils/turtleFetch'; +import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils'; import { readServeSimServersAsync } from '../serveSimMetricsRecorder'; import { sleepAsync } from '../../../utils/retry'; import { @@ -24,6 +25,7 @@ import { fetchWebPreviewTurnArgsAsync, parseServeSimLaunchInputs, simulatorPreviewPageUrl, + spawnDetached, startDeviceWebPreviewWithTunnelAsync, startExpoDeviceHubWithTunnelAsync, startNgrokTunnelAsync, @@ -531,6 +533,56 @@ describe(startNgrokTunnelAsync, () => { }); }); +describe(spawnDetached, () => { + const env = {} as BuildStepEnv; + + beforeEach(() => { + const spawned = Object.assign(Promise.resolve(undefined), { + child: { pid: 4321, unref: jest.fn() }, + }); + jest.mocked(spawn).mockReturnValue(spawned as never); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('waits for a detached child after its package-manager wrapper exits', async () => { + let groupChecks = 0; + const kill = jest.spyOn(process, 'kill').mockImplementation((pid, signal) => { + if (pid === -4321 && signal === 0) { + groupChecks += 1; + if (groupChecks < 4) { + return true; + } + throw new Error('Process group exited'); + } + if (pid === -4321 && signal === 'SIGTERM') { + return true; + } + throw new Error(`Unexpected process signal: ${pid} ${signal}`); + }); + + const detached = spawnDetached({ command: 'npx', args: [], env, stopTimeoutMs: 90_000 }); + await detached.stopAsync(); + + expect(jest.mocked(sleepAsync)).toHaveBeenCalledWith(100); + expect(kill).toHaveBeenCalledWith(-4321, 'SIGTERM'); + expect(kill).not.toHaveBeenCalledWith(-4321, 'SIGKILL'); + expect(kill).not.toHaveBeenCalledWith(4321, 0); + }); + + it('kills a detached child that outlives the shutdown deadline', async () => { + const kill = jest.spyOn(process, 'kill').mockReturnValue(true); + + const detached = spawnDetached({ command: 'npx', args: [], env, stopTimeoutMs: 0 }); + await detached.stopAsync(); + + expect(kill).toHaveBeenCalledWith(-4321, 'SIGTERM'); + expect(kill).toHaveBeenCalledWith(-4321, 'SIGKILL'); + }); +}); + describe(startDeviceWebPreviewWithTunnelAsync, () => { const baseDomain = 'eas-simulator.ngrok.dev'; const turnArgs = [ @@ -585,6 +637,10 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => { }); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + it('installs ffmpeg before starting expo-device-hub for Linux', async () => { const packageVersion = '1.2.3'; const close = jest.fn().mockResolvedValue(undefined); @@ -798,6 +854,8 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => { }); it('launches serve-sim with bun x when EAS_OVERRIDE_PACKAGE_MANAGER is bun', async () => { + const registerPackage = jest.spyOn(IosSimulatorRecordingUtils, 'registerServeSimPackage'); + const unregisterPackage = jest.spyOn(IosSimulatorRecordingUtils, 'unregisterServeSimPackage'); const close = jest.fn().mockResolvedValue(undefined); jest.mocked(ngrok.forward).mockResolvedValue({ url: () => 'https://ios-preview.example.test', @@ -827,8 +885,10 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => { packageVersion: '4.5.6', }), ]); + expect(registerPackage).toHaveBeenCalledWith('device-id', '@expo/serve-sim@4.5.6'); await preview.stopAsync(); + expect(unregisterPackage).toHaveBeenCalledWith('device-id', '@expo/serve-sim@4.5.6'); expect(close).toHaveBeenCalledTimes(1); }); diff --git a/packages/build-tools/src/steps/utils/localEgressGuard.ts b/packages/build-tools/src/steps/utils/localEgressGuard.ts index 90e2e520fd..642b68d36a 100644 --- a/packages/build-tools/src/steps/utils/localEgressGuard.ts +++ b/packages/build-tools/src/steps/utils/localEgressGuard.ts @@ -95,7 +95,6 @@ async function resolvePackagedFileAsync(binDir: string, file: string): Promise { diff --git a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts index e2afbc9f70..280be96809 100644 --- a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts +++ b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts @@ -34,6 +34,7 @@ import { import { sleepAsync } from '../../utils/retry'; import { turtleFetch } from '../../utils/turtleFetch'; import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder'; +import { IosSimulatorRecordingUtils } from './IosSimulatorRecordingUtils'; const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer'; const WEB_PREVIEW_HOST = '127.0.0.1'; @@ -559,8 +560,20 @@ function isProcessRunning(pid: number): boolean { } } -async function stopDetachedProcessAsync(pid: number | undefined): Promise { - if (pid === undefined || !isProcessRunning(pid)) { +function isDetachedProcessGroupRunning(pid: number): boolean { + try { + process.kill(-pid, 0); + return true; + } catch { + return false; + } +} + +async function stopDetachedProcessAsync( + pid: number | undefined, + stopTimeoutMs: number +): Promise { + if (pid === undefined || !isDetachedProcessGroupRunning(pid)) { return; } try { @@ -575,11 +588,11 @@ async function stopDetachedProcessAsync(pid: number | undefined): Promise } } - const deadline = Date.now() + 5_000; - while (Date.now() < deadline && isProcessRunning(pid)) { + const deadline = Date.now() + stopTimeoutMs; + while (Date.now() < deadline && isDetachedProcessGroupRunning(pid)) { await sleepAsync(100); } - if (!isProcessRunning(pid)) { + if (!isDetachedProcessGroupRunning(pid)) { return; } try { @@ -596,11 +609,13 @@ export function spawnDetached({ args, cwd, env, + stopTimeoutMs = 5_000, }: { command: string; args: string[]; cwd?: string; env: BuildStepEnv; + stopTimeoutMs?: number; }): DetachedProcessHandle { const promise = spawn(command, args, { cwd, @@ -624,7 +639,7 @@ export function spawnDetached({ return { pid, getOutput: () => output, - stopAsync: async () => await stopDetachedProcessAsync(pid), + stopAsync: async () => await stopDetachedProcessAsync(pid, stopTimeoutMs), }; } @@ -904,7 +919,10 @@ async function startWebPreviewWithTunnelAsync( serverName, packageSpec, createArgs, + serverStopTimeoutMs, readPreviewTokenAsync, + onReady, + onStop, }: { baseDomain: string; env: BuildStepEnv; @@ -913,7 +931,10 @@ async function startWebPreviewWithTunnelAsync( serverName: string; packageSpec: string; createArgs: (port: number, turnArgs: string[], previewPageUrl: string) => string[]; + serverStopTimeoutMs?: number; readPreviewTokenAsync?: (device: string) => Promise; + onReady?: (device: string) => void; + onStop?: (device: string) => void; } ): Promise { const subdomainId = randomBytes(16).toString('hex'); @@ -931,6 +952,7 @@ async function startWebPreviewWithTunnelAsync( command: previewExec.command, args: previewExec.args, env, + stopTimeoutMs: serverStopTimeoutMs, }); try { @@ -950,11 +972,13 @@ async function startWebPreviewWithTunnelAsync( authtoken: getNgrokAuthtokenOrThrow(env), logger, }); + onReady?.(device); return { previewPageUrl, apiUrl: tunnel.url, previewToken, stopAsync: async () => { + onStop?.(device); const results = await Promise.allSettled([tunnel.stopAsync(), previewServer.stopAsync()]); for (const result of results) { if (result.status === 'rejected') { @@ -997,13 +1021,15 @@ export async function startServeSimWithTunnelAsync( } & ServeSimLaunchOptions ): Promise { const websiteArgs = websiteOriginServeSimArgs(env); + const packageSpec = createServeSimPackageSpec(packageVersion); return await startWebPreviewWithTunnelAsync(ctx, { baseDomain, env, logger, timeoutMs, serverName: 'serve-sim', - packageSpec: createServeSimPackageSpec(packageVersion), + packageSpec, + serverStopTimeoutMs: 90_000, createArgs: (port, turnArgs, previewPageUrl) => createServeSimArgs({ port, @@ -1029,6 +1055,8 @@ export async function startServeSimWithTunnelAsync( } return previewToken; }, + onReady: device => IosSimulatorRecordingUtils.registerServeSimPackage(device, packageSpec), + onStop: device => IosSimulatorRecordingUtils.unregisterServeSimPackage(device, packageSpec), }); } diff --git a/packages/worker/package.sh b/packages/worker/package.sh index 69b0990792..666f55b49b 100755 --- a/packages/worker/package.sh +++ b/packages/worker/package.sh @@ -23,21 +23,16 @@ fi echo "Building $OUTPUT_FILE" tmp_dir="" -record_sim_build_dir="" cleanup() { if [[ -n "$tmp_dir" ]]; then rm -rf "$tmp_dir" fi - if [[ -n "$record_sim_build_dir" ]]; then - rm -rf "$record_sim_build_dir" - fi } trap cleanup EXIT tmp_dir=$(mktemp -d) target_root_dir="$tmp_dir" target_worker_dir="$tmp_dir/packages/worker" -record_sim_build_dir=$(mktemp -d) mkdir -p "$target_worker_dir" @@ -98,29 +93,16 @@ rm -rf tsconfig.json tsconfig.build.json popd >/dev/null 2>&1 if [[ "$PLATFORM" != "ios" ]]; then - rm -f "$target_root_dir/packages/build-tools/bin/record-sim" rm -f "$target_root_dir/packages/build-tools/bin/egress-guard.dylib" fi if [[ "$PLATFORM" == "ios" ]]; then - record_sim_package_dir="$ROOT_DIR/packages/build-tools/resources/record-sim" - record_sim_bin_dir="$target_root_dir/packages/build-tools/bin" - mkdir -p "$record_sim_bin_dir" - record_sim_bin_path=$(swift build \ - -c release \ - --package-path "$record_sim_package_dir" \ - --build-path "$record_sim_build_dir" \ - --show-bin-path) - swift build \ - -c release \ - --package-path "$record_sim_package_dir" \ - --build-path "$record_sim_build_dir" - cp "$record_sim_bin_path/record-sim" "$record_sim_bin_dir/record-sim" - chmod +x "$record_sim_bin_dir/record-sim" + build_tools_bin_dir="$target_root_dir/packages/build-tools/bin" + mkdir -p "$build_tools_bin_dir" # The local egress guard, injected into simulator processes; see # packages/build-tools/resources/egress-guard/README.md. - "$ROOT_DIR/packages/build-tools/resources/egress-guard/build.sh" "$record_sim_bin_dir" + "$ROOT_DIR/packages/build-tools/resources/egress-guard/build.sh" "$build_tools_bin_dir" # build plugin pushd "$ROOT_DIR/packages/expo-cocoapods-proxy" >/dev/null 2>&1 From a9ac7da2536f3a252a06f89679780b056c3cc73a Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sun, 27 Sep 2026 17:01:37 -0700 Subject: [PATCH 2/2] [build-tools] Track the recorder after package-manager exit Co-authored-by: Codex --- .../steps/utils/IosSimulatorRecordingUtils.ts | 45 +++++++++++++----- .../IosSimulatorRecordingUtils.test.ts | 46 +++++++++++++++++++ 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/packages/build-tools/src/steps/utils/IosSimulatorRecordingUtils.ts b/packages/build-tools/src/steps/utils/IosSimulatorRecordingUtils.ts index 3c951556d9..8871db4cae 100644 --- a/packages/build-tools/src/steps/utils/IosSimulatorRecordingUtils.ts +++ b/packages/build-tools/src/steps/utils/IosSimulatorRecordingUtils.ts @@ -338,13 +338,18 @@ async function startIosSimulatorRecordingAsync( }); return true; }; - const completionPromise = recordingSpawn - .then(async () => { - if (!(await saveFinalizedRecordingAsync())) { - scheduleRecordingRetry(session, udid); - } - }) - .catch(async (err: unknown) => { + const completionPromise = (async () => { + let spawnError: unknown; + try { + await recordingSpawn; + } catch (err) { + spawnError = err; + } + // A package-manager wrapper can exit while its record-video child still + // runs in the detached group. Keep this device active until the group exits. + await waitForRecordingProcessGroupExitAsync(recordingSpawn.child); + if (spawnError) { + const err = spawnError; const error = err instanceof Error ? err : new Error(String(err)); Sentry.capture('iOS Simulator screen recording process failed', error); session.logger.warn( @@ -354,10 +359,12 @@ async function startIosSimulatorRecordingAsync( if (!hasStarted() || !(await saveFinalizedRecordingAsync())) { scheduleRecordingRetry(session, udid); } - }) - .finally(() => { - session.activeRecordings.delete(udid); - }); + } else if (!(await saveFinalizedRecordingAsync())) { + scheduleRecordingRetry(session, udid); + } + })().finally(() => { + session.activeRecordings.delete(udid); + }); session.activeRecordings.set(udid, { id: recordingId, @@ -431,6 +438,22 @@ function signalRecordingProcess(child: ChildProcess, signal: NodeJS.Signals): vo child.kill(signal); } +async function waitForRecordingProcessGroupExitAsync(child: ChildProcess): Promise { + if (child.pid === undefined) { + return; + } + while (true) { + try { + process.kill(-child.pid, 0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EPERM') { + return; + } + } + await setTimeout(100); + } +} + function captureProcessOutput(recordingProcess: ChildProcess): { getOutput: () => string; hasStarted: () => boolean; diff --git a/packages/build-tools/src/steps/utils/__tests__/IosSimulatorRecordingUtils.test.ts b/packages/build-tools/src/steps/utils/__tests__/IosSimulatorRecordingUtils.test.ts index f9110d2f5e..345c83d432 100644 --- a/packages/build-tools/src/steps/utils/__tests__/IosSimulatorRecordingUtils.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/IosSimulatorRecordingUtils.test.ts @@ -191,3 +191,49 @@ test('does not start a second recording for a server token after a late manifest expect(spawn).toHaveBeenCalledTimes(1); expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toHaveLength(1); }); + +test('keeps a recorder active after its package-manager wrapper exits', async () => { + jest + .mocked(readServeSimServersAsync) + .mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]); + let groupRunning = true; + const originalKill = process.kill.bind(process); + const signalGroup = jest.spyOn(process, 'kill').mockImplementation((pid, signal) => { + if (pid !== -4321) { + return originalKill(pid, signal); + } + if (signal === 0) { + if (groupRunning) { + return true; + } + throw Object.assign(new Error('No such process group'), { code: 'ESRCH' }); + } + if (signal === 'SIGINT') { + void setTimeout(20).then(async () => { + const args = jest.mocked(spawn).mock.calls[0][1]; + await writeFile(`${args[6]}/session.json`, JSON.stringify({ recording: 'recording.mp4' })); + groupRunning = false; + }); + return true; + } + throw new Error(`Unexpected signal: ${signal}`); + }); + const child = Object.assign(new EventEmitter(), { + pid: 4321, + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: jest.fn(), + }) as unknown as ChildProcess; + jest.mocked(spawn).mockReturnValue(Object.assign(Promise.resolve(), { child }) as never); + + await IosSimulatorRecordingUtils.startAsync({ env, logger }); + await waitForRecorderSpawnAsync(); + child.stderr?.emit('data', 'serve-sim:recording-started\n'); + await setTimeout(2_100); + expect(spawn).toHaveBeenCalledTimes(1); + + const recordings = await IosSimulatorRecordingUtils.finishAsync({ logger }); + expect(signalGroup).toHaveBeenCalledWith(-4321, 'SIGINT'); + expect(child.kill).not.toHaveBeenCalled(); + expect(recordings).toHaveLength(1); +});