mirror of
https://github.com/xtool-org/xtool.git
synced 2026-11-15 12:04:55 +03:00
bca6f59848
## The bug `DeveloperServicesAssignAppGroupsOperation` sends one `assignApplicationGroupToAppId` request per app group, concurrently. That endpoint replaces the App ID's groups with the ones in the request, so an app whose entitlements list two or more groups ends up with only one of them on the App ID: whichever request Apple handles last. The provisioning profile then carries one group while the signed binary claims two, and the device refuses the install with `ApplicationVerificationFailed`. Setting the groups by hand in the developer portal does not help, because the next `xtool dev build --sign` sends the per-group requests again. ## The fix - `DeveloperServicesAssignAppGroupRequest` takes `groupIDs: [DeveloperServicesAppGroup.ID]` and sends `applicationGroups` as an array. The endpoint accepts an array; it was being given a single string before. - `DeveloperServicesAssignAppGroupsOperation.perform()` still finds or creates each group concurrently, then sends a single assign request carrying every group ID, and returns the group IDs in the order the entitlements listed them, so the rewritten entitlement is stable between builds. The only caller, `DeveloperServicesAddAppOperation`, is unchanged. ## Related #324 would report the resulting mismatch between the entitlements and the profile as a clear error at signing time; this change removes the cause, so an app with several groups gets a profile that grants all of them. ## Checked Built on Linux with the Swift 6.4 toolchain and used to sign an iOS app whose main target has two app groups and a Share extension. The app's `embedded.mobileprovision` now carries both groups and the device installs the app. The same app signed with the unpatched build got one group in the profile and `ApplicationVerificationFailed` on install. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01UKzxgxaLwK7Ap5LMwj9MxH <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **New Features** * Apps can now be assigned multiple app groups in a single operation. The assignment replaces the app’s existing group associations with the selected groups, so any groups not included in the new assignment are no longer associated with the app. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: David Reinecke <david.reinecke@gmail.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Kabir Oberai <oberai.kabir@gmail.com>
228 lines
8.3 KiB
Swift
228 lines
8.3 KiB
Swift
import Testing
|
|
@testable import XUtils
|
|
|
|
@Suite(.timeLimit(.minutes(1)))
|
|
struct ConcurrentMapTests {
|
|
@Test(arguments: [[], [7], [3, 1, 3, 2], Array(0..<10)], [nil, 1, 2, Int.max] as [Int?])
|
|
func nonThrowingTransform(input: [Int], maxConcurrency: Int?) async {
|
|
// No `try` is needed when the transform cannot throw.
|
|
let result = await input.concurrentMap(maxConcurrency: maxConcurrency) { String($0 * 2) }
|
|
#expect(result == input.map { String($0 * 2) })
|
|
}
|
|
|
|
@Test func emptyInputDoesNotCallTransform() async throws {
|
|
let result = try await [Int]().concurrentMap { _ throws(MapError) -> Int in
|
|
Issue.record("An empty sequence must not invoke the transform")
|
|
throw .failed(0)
|
|
}
|
|
#expect(result.isEmpty)
|
|
}
|
|
|
|
@Test func singlePassSequence() async {
|
|
var next = 0
|
|
let input = AnySequence(AnyIterator<Int> {
|
|
defer { next += 1 }
|
|
return next < 4 ? next : nil
|
|
})
|
|
#expect(input.underestimatedCount == 0)
|
|
|
|
let result = await input.concurrentMap(maxConcurrency: 1) { $0 * 2 }
|
|
|
|
#expect(result == [0, 2, 4, 6])
|
|
}
|
|
|
|
@Test func optionalResults() async {
|
|
let result = await (0..<4).concurrentMap { value -> Int? in
|
|
value.isMultiple(of: 2) ? nil : value
|
|
}
|
|
#expect(result == [nil, 1, nil, 3])
|
|
}
|
|
|
|
@Test func nestedOptionalResults() async {
|
|
let input: [Int??] = [.none, .some(.none), .some(.some(3))]
|
|
let result = await input.concurrentMap { $0 }
|
|
#expect(result == input)
|
|
}
|
|
|
|
@Test(arguments: [2, 4, Int.max])
|
|
func concurrentExecutionPreservesInputOrder(maxConcurrency: Int) async {
|
|
let started = AsyncStream<Int>.makeStream()
|
|
let finished = AsyncStream<Int>.makeStream()
|
|
let gates = (0..<4).map { _ in AsyncStream<Void>.makeStream() }
|
|
let task = Task {
|
|
await (0..<4).concurrentMap(maxConcurrency: maxConcurrency) { value in
|
|
started.continuation.yield(value)
|
|
for await _ in gates[value].stream {}
|
|
finished.continuation.yield(value)
|
|
return value * 2
|
|
}
|
|
}
|
|
defer { task.cancel() }
|
|
|
|
// Fill the concurrency window before allowing any transform to finish.
|
|
let initialCount = min(maxConcurrency, 4)
|
|
var starts = started.stream.makeAsyncIterator()
|
|
var startedValues: [Int] = []
|
|
for _ in 0..<initialCount {
|
|
if let value = await starts.next() {
|
|
startedValues.append(value)
|
|
}
|
|
}
|
|
#expect(startedValues.sorted() == Array(0..<initialCount))
|
|
|
|
// Finish the newest transform first, keeping the first one suspended while work is replenished.
|
|
var finishes = finished.stream.makeAsyncIterator()
|
|
var pending = Set(startedValues)
|
|
var next = initialCount
|
|
while let value = pending.max() {
|
|
pending.remove(value)
|
|
gates[value].continuation.finish()
|
|
#expect(await finishes.next() == value)
|
|
if next < 4 {
|
|
#expect(await starts.next() == next)
|
|
pending.insert(next)
|
|
next += 1
|
|
}
|
|
}
|
|
#expect(await task.value == [0, 2, 4, 6])
|
|
}
|
|
|
|
@Test(arguments: [1, 2, 3, 25, 100, Int.max])
|
|
func limitsConcurrentTransforms(maxConcurrency: Int) async {
|
|
let checkpoints = AsyncStream<Int>.makeStream()
|
|
let gates = (0..<25).map { _ in AsyncStream<Void>.makeStream() }
|
|
let task = Task {
|
|
// Both closures below run in the producer task. Only immutable snapshots
|
|
// cross to the test through the checkpoint stream.
|
|
var submitted = 0
|
|
let input = AnySequence(AnyIterator<Int> {
|
|
guard submitted < gates.count else { return nil }
|
|
defer { submitted += 1 }
|
|
return submitted
|
|
})
|
|
return await input._concurrentMap(maxConcurrency: maxConcurrency) { value in
|
|
for await _ in gates[value].stream {}
|
|
return value * 2
|
|
} willWaitForNext: {
|
|
checkpoints.continuation.yield(submitted)
|
|
}
|
|
}
|
|
defer { task.cancel() }
|
|
|
|
// No transform can finish yet. The hook proves submission has reached
|
|
// group.next(), rather than merely being paused by the scheduler.
|
|
let initialCount = min(maxConcurrency, gates.count)
|
|
var waits = checkpoints.stream.makeAsyncIterator()
|
|
#expect(await waits.next() == initialCount)
|
|
|
|
// Each completed transform frees exactly one slot. Check the next submission
|
|
// boundary before releasing another transform, including while draining.
|
|
for value in gates.indices {
|
|
gates[value].continuation.finish()
|
|
#expect(await waits.next() == min(initialCount + value + 1, gates.count))
|
|
}
|
|
let result = await withTaskCancellationHandler {
|
|
await task.value
|
|
} onCancel: {
|
|
task.cancel()
|
|
}
|
|
|
|
#expect(result == (0..<25).map { $0 * 2 })
|
|
}
|
|
|
|
@Test @MainActor func actorIsolatedTransform() async {
|
|
var visited: [Int] = []
|
|
let result = await (0..<4).concurrentMap(maxConcurrency: 2) { @MainActor value in
|
|
MainActor.assertIsolated()
|
|
visited.append(value)
|
|
return value * 2
|
|
}
|
|
|
|
#expect(visited.sorted() == [0, 1, 2, 3])
|
|
#expect(result == [0, 2, 4, 6])
|
|
}
|
|
|
|
@Test func preservesTypedError() async {
|
|
// This signature also checks that concurrentMap preserves the error type.
|
|
func map() async throws(MapError) -> [Int] {
|
|
try await [42].concurrentMap { value throws(MapError) -> Int in
|
|
throw .failed(value)
|
|
}
|
|
}
|
|
|
|
await #expect(throws: MapError.failed(42)) {
|
|
try await map()
|
|
}
|
|
}
|
|
|
|
@Test func failureCancelsUnfinishedTransforms() async {
|
|
let started = AsyncStream<Int>.makeStream()
|
|
let failureGate = AsyncStream<Void>.makeStream()
|
|
let siblingGate = AsyncStream<Void>.makeStream()
|
|
let cancelled = AsyncStream<Int>.makeStream()
|
|
let task = Task {
|
|
try await (0..<10).concurrentMap(maxConcurrency: 3) { value async throws(MapError) -> Int in
|
|
#expect(value < 3, "A failure must not schedule the remaining inputs")
|
|
started.continuation.yield(value)
|
|
if value == 1 {
|
|
for await _ in failureGate.stream {}
|
|
throw .failed(value)
|
|
}
|
|
// This stream only finishes when the task is cancelled.
|
|
for await _ in siblingGate.stream {}
|
|
#expect(Task.isCancelled)
|
|
cancelled.continuation.yield(value)
|
|
return value
|
|
}
|
|
}
|
|
defer { task.cancel() }
|
|
|
|
var starts = started.stream.makeAsyncIterator()
|
|
for _ in 0..<3 {
|
|
_ = await starts.next()
|
|
}
|
|
failureGate.continuation.finish()
|
|
|
|
await #expect(throws: MapError.failed(1)) {
|
|
try await withTaskCancellationHandler {
|
|
try await task.value
|
|
} onCancel: {
|
|
// Let the suite's time limit break a regression in sibling cancellation.
|
|
task.cancel()
|
|
}
|
|
}
|
|
// The task group must wait for both cancelled transforms to exit.
|
|
cancelled.continuation.finish()
|
|
var cancelledValues: [Int] = []
|
|
for await value in cancelled.stream {
|
|
cancelledValues.append(value)
|
|
}
|
|
#expect(cancelledValues.sorted() == [0, 2])
|
|
}
|
|
|
|
@Test func parentCancellationReachesTransforms() async {
|
|
let started = AsyncStream<Void>.makeStream()
|
|
let gate = AsyncStream<Void>.makeStream()
|
|
let task = Task {
|
|
try await [1].concurrentMap { _ in
|
|
started.continuation.yield(())
|
|
for await _ in gate.stream {}
|
|
try Task.checkCancellation()
|
|
}
|
|
}
|
|
defer { task.cancel() }
|
|
|
|
var starts = started.stream.makeAsyncIterator()
|
|
_ = await starts.next()
|
|
task.cancel()
|
|
|
|
await #expect(throws: CancellationError.self) {
|
|
try await task.value
|
|
}
|
|
}
|
|
|
|
private enum MapError: Error, Equatable {
|
|
case failed(Int)
|
|
}
|
|
}
|