mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
### Summary of Changes Previously, on the Swift SDK, the `signInWithOAuth` function wasn't working. In this PR, we fix it by having the `getOAuthUrl` function to actually redirect correctly. Note that to do so, we updated the `validRedirectUrl` check on the backend to accept app native redirects (from our new trusted url scheme). Another thing to note is that we added functionality to the `TokenStore` abstraction to conditionally refresh the access token that the user is trying to fetch if it is expired/close to expiring if possible. `getOAuthUrl` will attempt to get a valid access token, and thus will rely on our algorithm documented in `utilities.md`. The specs serve as the source of truth. We go further and implement Apple Native sign in. To do so, we have it hit a new route on the backend and verify the `jwtToken` retrieved by the sdk against an Apple-provided set of `jwks`. We use jose to do so, in line with the rest of the codebase. We take this opportunity to refactor the oauth provider route owing to the amount of duplicated logic. Additionally, to enable the apple sign in, users will have to update the Apple authentication method modal on the dashboard and add accepted bundle ids. These are identifiers for projects, and we will check the `JWT` on the backend to make sure the audience is set to an accepted bundleId. We also update the Apple modal to be more informative. ### Using the new Features To use the Apple native sign in, users will have to 1) sign up with an apple developer account, 2) set up their bundleids for their projects by connecting them to the apple developer account, 3) update the Stack-Auth Authentication Methods dashboard apple modal with the relevant fields. Then, trying to sign in with apple with our Swift SDK will use the apple native sign in. ### UI Changes Renamed the fields in the apple modal. Added a new field for bundle ids. See below. https://github.com/user-attachments/assets/0e760c0e-3198-4818-ac7f-4900d7a125bb Co-authored-by: Konstantin Wohlwend <n2d4xc@gmail.com>
538 lines
20 KiB
Swift
538 lines
20 KiB
Swift
import Testing
|
|
import Foundation
|
|
@testable import StackAuth
|
|
|
|
@Suite("Token Refresh Algorithm Tests")
|
|
struct TokenRefreshAlgorithmTests {
|
|
|
|
// MARK: - JWT Payload Decoding Tests
|
|
|
|
@Test("Should decode valid JWT payload")
|
|
func decodeValidJwt() {
|
|
// Create a simple JWT with exp and iat claims
|
|
// Header: {"alg":"HS256","typ":"JWT"}
|
|
// Payload: {"exp":9999999999,"iat":1000000000,"sub":"test"}
|
|
let header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
|
let payload = "eyJleHAiOjk5OTk5OTk5OTksImlhdCI6MTAwMDAwMDAwMCwic3ViIjoidGVzdCJ9"
|
|
let signature = "signature"
|
|
let jwt = "\(header).\(payload).\(signature)"
|
|
|
|
let decoded = decodeJWTPayload(jwt)
|
|
|
|
#expect(decoded != nil)
|
|
#expect(decoded?.exp == 9999999999)
|
|
#expect(decoded?.iat == 1000000000)
|
|
}
|
|
|
|
@Test("Should return nil for invalid JWT format")
|
|
func decodeInvalidJwt() {
|
|
let invalid1 = "not-a-jwt"
|
|
let invalid2 = "only.two"
|
|
let invalid3 = ""
|
|
|
|
#expect(decodeJWTPayload(invalid1) == nil)
|
|
#expect(decodeJWTPayload(invalid2) == nil)
|
|
#expect(decodeJWTPayload(invalid3) == nil)
|
|
}
|
|
|
|
@Test("Should handle JWT without exp claim")
|
|
func decodeJwtWithoutExp() {
|
|
// Payload: {"iat":1000000000,"sub":"test"} (no exp)
|
|
let header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
|
let payload = "eyJpYXQiOjEwMDAwMDAwMDAsInN1YiI6InRlc3QifQ"
|
|
let signature = "signature"
|
|
let jwt = "\(header).\(payload).\(signature)"
|
|
|
|
let decoded = decodeJWTPayload(jwt)
|
|
|
|
#expect(decoded != nil)
|
|
#expect(decoded?.exp == nil)
|
|
#expect(decoded?.expiresInMillis == Int.max) // No exp means never expires
|
|
}
|
|
|
|
@Test("Should handle JWT without iat claim")
|
|
func decodeJwtWithoutIat() {
|
|
// Payload: {"exp":9999999999,"sub":"test"} (no iat)
|
|
let header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
|
let payload = "eyJleHAiOjk5OTk5OTk5OTksInN1YiI6InRlc3QifQ"
|
|
let signature = "signature"
|
|
let jwt = "\(header).\(payload).\(signature)"
|
|
|
|
let decoded = decodeJWTPayload(jwt)
|
|
|
|
#expect(decoded != nil)
|
|
#expect(decoded?.iat == nil)
|
|
#expect(decoded?.issuedMillisAgo == 0) // No iat means issued at epoch
|
|
}
|
|
|
|
// MARK: - Token Expiration Tests
|
|
|
|
@Test("Should detect expired token")
|
|
func detectExpiredToken() {
|
|
// Payload with exp in the past (year 2000)
|
|
let header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
|
let payload = "eyJleHAiOjk0NjY4NDgwMCwic3ViIjoidGVzdCJ9" // exp: 946684800 (Jan 1, 2000)
|
|
let signature = "signature"
|
|
let jwt = "\(header).\(payload).\(signature)"
|
|
|
|
#expect(isTokenExpired(jwt) == true)
|
|
}
|
|
|
|
@Test("Should detect non-expired token")
|
|
func detectNonExpiredToken() {
|
|
// Payload with exp far in the future (year 2286)
|
|
let header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
|
let payload = "eyJleHAiOjk5OTk5OTk5OTksInN1YiI6InRlc3QifQ" // exp: 9999999999
|
|
let signature = "signature"
|
|
let jwt = "\(header).\(payload).\(signature)"
|
|
|
|
#expect(isTokenExpired(jwt) == false)
|
|
}
|
|
|
|
@Test("Should treat nil token as expired")
|
|
func nilTokenIsExpired() {
|
|
#expect(isTokenExpired(nil) == true)
|
|
}
|
|
|
|
@Test("Should treat invalid token as expired")
|
|
func invalidTokenIsExpired() {
|
|
#expect(isTokenExpired("not-a-jwt") == true)
|
|
}
|
|
|
|
// MARK: - Token Freshness Tests
|
|
|
|
@Test("Should consider token with long expiry AND recent issue as fresh")
|
|
func tokenWithLongExpiryAndRecentIssueIsFresh() {
|
|
// Token must BOTH: expire in >20s AND be issued <75s ago
|
|
let now = Int(Date().timeIntervalSince1970)
|
|
let iat = now - 10 // Issued 10 seconds ago (<75s) ✓
|
|
let exp = now + 3600 // Expires in 1 hour (>20s) ✓
|
|
|
|
let payloadJson = "{\"exp\":\(exp),\"iat\":\(iat),\"sub\":\"test\"}"
|
|
let payloadBase64 = Data(payloadJson.utf8).base64EncodedString()
|
|
.replacingOccurrences(of: "+", with: "-")
|
|
.replacingOccurrences(of: "/", with: "_")
|
|
.replacingOccurrences(of: "=", with: "")
|
|
|
|
let header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
|
let jwt = "\(header).\(payloadBase64).signature"
|
|
|
|
// Both conditions met, so token is fresh
|
|
#expect(isTokenFreshEnough(jwt) == true)
|
|
}
|
|
|
|
@Test("Should consider token fresh only when BOTH conditions met")
|
|
func tokenFreshWhenBothConditionsMet() {
|
|
// Token must BOTH: expire in >20s AND be issued <75s ago
|
|
let now = Int(Date().timeIntervalSince1970)
|
|
let iat = now - 30 // Issued 30 seconds ago (<75s) ✓
|
|
let exp = now + 60 // Expires in 60 seconds (>20s) ✓
|
|
|
|
// Manually construct JWT payload
|
|
let payloadJson = "{\"exp\":\(exp),\"iat\":\(iat),\"sub\":\"test\"}"
|
|
let payloadBase64 = Data(payloadJson.utf8).base64EncodedString()
|
|
.replacingOccurrences(of: "+", with: "-")
|
|
.replacingOccurrences(of: "/", with: "_")
|
|
.replacingOccurrences(of: "=", with: "")
|
|
|
|
let header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
|
let jwt = "\(header).\(payloadBase64).signature"
|
|
|
|
// Both conditions met, so token is fresh
|
|
#expect(isTokenFreshEnough(jwt) == true)
|
|
}
|
|
|
|
@Test("Should not consider token fresh if only recently issued")
|
|
func tokenNotFreshIfOnlyRecentlyIssued() {
|
|
// Token issued recently but expires soon
|
|
let now = Int(Date().timeIntervalSince1970)
|
|
let iat = now - 30 // Issued 30 seconds ago (<75s) ✓
|
|
let exp = now + 10 // Expires in 10 seconds (<20s) ✗
|
|
|
|
let payloadJson = "{\"exp\":\(exp),\"iat\":\(iat),\"sub\":\"test\"}"
|
|
let payloadBase64 = Data(payloadJson.utf8).base64EncodedString()
|
|
.replacingOccurrences(of: "+", with: "-")
|
|
.replacingOccurrences(of: "/", with: "_")
|
|
.replacingOccurrences(of: "=", with: "")
|
|
|
|
let header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
|
let jwt = "\(header).\(payloadBase64).signature"
|
|
|
|
// Only one condition met, should refresh
|
|
#expect(isTokenFreshEnough(jwt) == false)
|
|
}
|
|
|
|
@Test("Should not consider token fresh if only has long expiry")
|
|
func tokenNotFreshIfOnlyLongExpiry() {
|
|
// Token has long expiry but was issued long ago
|
|
let now = Int(Date().timeIntervalSince1970)
|
|
let iat = now - 100 // Issued 100 seconds ago (>75s) ✗
|
|
let exp = now + 60 // Expires in 60 seconds (>20s) ✓
|
|
|
|
let payloadJson = "{\"exp\":\(exp),\"iat\":\(iat),\"sub\":\"test\"}"
|
|
let payloadBase64 = Data(payloadJson.utf8).base64EncodedString()
|
|
.replacingOccurrences(of: "+", with: "-")
|
|
.replacingOccurrences(of: "/", with: "_")
|
|
.replacingOccurrences(of: "=", with: "")
|
|
|
|
let header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
|
let jwt = "\(header).\(payloadBase64).signature"
|
|
|
|
// Only one condition met, should refresh
|
|
#expect(isTokenFreshEnough(jwt) == false)
|
|
}
|
|
|
|
@Test("Should consider nil token as not fresh")
|
|
func nilTokenIsNotFresh() {
|
|
#expect(isTokenFreshEnough(nil) == false)
|
|
}
|
|
|
|
@Test("Should consider invalid token as not fresh")
|
|
func invalidTokenIsNotFresh() {
|
|
#expect(isTokenFreshEnough("not-a-jwt") == false)
|
|
}
|
|
|
|
// MARK: - Compare And Set Tests
|
|
|
|
@Test("Should update tokens when refresh token matches")
|
|
func compareAndSetWhenMatching() async {
|
|
let store = MemoryTokenStore()
|
|
await store.setTokens(accessToken: "old-access", refreshToken: "original-refresh")
|
|
|
|
await store.compareAndSet(
|
|
compareRefreshToken: "original-refresh",
|
|
newRefreshToken: "new-refresh",
|
|
newAccessToken: "new-access"
|
|
)
|
|
|
|
let accessToken = await store.getStoredAccessToken()
|
|
let refreshToken = await store.getStoredRefreshToken()
|
|
|
|
#expect(accessToken == "new-access")
|
|
#expect(refreshToken == "new-refresh")
|
|
}
|
|
|
|
@Test("Should not update tokens when refresh token doesn't match")
|
|
func compareAndSetWhenNotMatching() async {
|
|
let store = MemoryTokenStore()
|
|
await store.setTokens(accessToken: "old-access", refreshToken: "current-refresh")
|
|
|
|
// Try to update with wrong compare token
|
|
await store.compareAndSet(
|
|
compareRefreshToken: "wrong-refresh",
|
|
newRefreshToken: "new-refresh",
|
|
newAccessToken: "new-access"
|
|
)
|
|
|
|
let accessToken = await store.getStoredAccessToken()
|
|
let refreshToken = await store.getStoredRefreshToken()
|
|
|
|
// Should remain unchanged
|
|
#expect(accessToken == "old-access")
|
|
#expect(refreshToken == "current-refresh")
|
|
}
|
|
|
|
@Test("Should clear tokens when setting nil")
|
|
func compareAndSetWithNil() async {
|
|
let store = MemoryTokenStore()
|
|
await store.setTokens(accessToken: "old-access", refreshToken: "original-refresh")
|
|
|
|
await store.compareAndSet(
|
|
compareRefreshToken: "original-refresh",
|
|
newRefreshToken: nil,
|
|
newAccessToken: nil
|
|
)
|
|
|
|
let accessToken = await store.getStoredAccessToken()
|
|
let refreshToken = await store.getStoredRefreshToken()
|
|
|
|
#expect(accessToken == nil)
|
|
#expect(refreshToken == nil)
|
|
}
|
|
|
|
// MARK: - Integration Tests with Real Tokens
|
|
|
|
@Test("Should refresh token and return new access token")
|
|
func refreshTokenIntegration() async throws {
|
|
let app = TestConfig.createClientApp()
|
|
let email = TestConfig.uniqueEmail()
|
|
|
|
try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
|
|
|
|
let tokensBefore = await app.getAccessToken()
|
|
#expect(tokensBefore != nil)
|
|
|
|
// Wait a tiny bit to ensure different token if refreshed
|
|
try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
|
|
|
|
// Force fetch a new token
|
|
// Note: This will only actually refresh if the token needs it
|
|
let tokensAfter = await app.getAccessToken()
|
|
#expect(tokensAfter != nil)
|
|
|
|
// Both should be valid JWTs
|
|
let partsBefore = tokensBefore!.split(separator: ".")
|
|
let partsAfter = tokensAfter!.split(separator: ".")
|
|
#expect(partsBefore.count == 3)
|
|
#expect(partsAfter.count == 3)
|
|
}
|
|
|
|
@Test("Should return nil when no tokens exist")
|
|
func noTokensReturnsNil() async {
|
|
let app = TestConfig.createClientApp()
|
|
|
|
// Not signed in, should return nil
|
|
let accessToken = await app.getAccessToken()
|
|
let refreshToken = await app.getRefreshToken()
|
|
|
|
#expect(accessToken == nil)
|
|
#expect(refreshToken == nil)
|
|
}
|
|
|
|
@Test("Should handle concurrent getAccessToken calls")
|
|
func concurrentGetAccessToken() async throws {
|
|
let app = TestConfig.createClientApp()
|
|
let email = TestConfig.uniqueEmail()
|
|
|
|
try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
|
|
|
|
// Make multiple concurrent calls
|
|
async let token1 = app.getAccessToken()
|
|
async let token2 = app.getAccessToken()
|
|
async let token3 = app.getAccessToken()
|
|
|
|
let results = await [token1, token2, token3]
|
|
|
|
// All should return a valid token
|
|
for token in results {
|
|
#expect(token != nil)
|
|
#expect(token!.split(separator: ".").count == 3)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - RefreshLockManager Concurrency Tests
|
|
|
|
/// Minimal token store for lock testing - we only need an object identity for the lock key
|
|
private actor MockTokenStore: TokenStoreProtocol {
|
|
func getStoredAccessToken() async -> String? { nil }
|
|
func getStoredRefreshToken() async -> String? { nil }
|
|
func setTokens(accessToken: String?, refreshToken: String?) async {}
|
|
func clearTokens() async {}
|
|
func compareAndSet(compareRefreshToken: String, newRefreshToken: String?, newAccessToken: String?) async {}
|
|
}
|
|
|
|
@Suite("RefreshLockManager Concurrency Tests")
|
|
struct RefreshLockManagerTests {
|
|
|
|
@Test("Should serialize concurrent lock acquisitions")
|
|
func serializeConcurrentLocks() async {
|
|
let store = MockTokenStore()
|
|
var executionOrder: [Int] = []
|
|
let orderLock = NSLock()
|
|
|
|
func appendOrder(_ n: Int) {
|
|
orderLock.lock()
|
|
executionOrder.append(n)
|
|
orderLock.unlock()
|
|
}
|
|
|
|
// Start 3 concurrent tasks that all try to acquire the lock
|
|
await withTaskGroup(of: Void.self) { group in
|
|
for i in 1...3 {
|
|
group.addTask {
|
|
await RefreshLockManager.shared.acquireLock(for: store)
|
|
appendOrder(i * 10) // Record entry: 10, 20, or 30
|
|
// Simulate some work while holding the lock
|
|
try? await Task.sleep(nanoseconds: 10_000_000) // 10ms
|
|
appendOrder(i * 10 + 1) // Record exit: 11, 21, or 31
|
|
await RefreshLockManager.shared.releaseLock(for: store)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify serialization: each task should complete (entry+exit) before next starts
|
|
// Valid patterns: [10,11,20,21,30,31], [10,11,30,31,20,21], [20,21,10,11,30,31], etc.
|
|
// Invalid: [10,20,11,21,...] - interleaved entries/exits
|
|
|
|
#expect(executionOrder.count == 6)
|
|
|
|
// Check that entries and exits are paired (no interleaving)
|
|
var inProgress: Int? = nil
|
|
for event in executionOrder {
|
|
let taskId = event / 10
|
|
let isEntry = event % 10 == 0
|
|
|
|
if isEntry {
|
|
// Should not have another task in progress when entering
|
|
#expect(inProgress == nil, "Task \(taskId) entered while task \(inProgress ?? -1) was in progress")
|
|
inProgress = taskId
|
|
} else {
|
|
// Should be exiting the same task that entered
|
|
#expect(inProgress == taskId, "Task \(taskId) exited but task \(inProgress ?? -1) was in progress")
|
|
inProgress = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test("Should allow different stores to lock concurrently")
|
|
func differentStoresCanLockConcurrently() async {
|
|
let store1 = MockTokenStore()
|
|
let store2 = MockTokenStore()
|
|
var concurrentCount = 0
|
|
var maxConcurrent = 0
|
|
let countLock = NSLock()
|
|
|
|
func incrementConcurrent() {
|
|
countLock.lock()
|
|
concurrentCount += 1
|
|
if concurrentCount > maxConcurrent {
|
|
maxConcurrent = concurrentCount
|
|
}
|
|
countLock.unlock()
|
|
}
|
|
|
|
func decrementConcurrent() {
|
|
countLock.lock()
|
|
concurrentCount -= 1
|
|
countLock.unlock()
|
|
}
|
|
|
|
await withTaskGroup(of: Void.self) { group in
|
|
// Task for store1
|
|
group.addTask {
|
|
await RefreshLockManager.shared.acquireLock(for: store1)
|
|
incrementConcurrent()
|
|
try? await Task.sleep(nanoseconds: 50_000_000) // 50ms
|
|
decrementConcurrent()
|
|
await RefreshLockManager.shared.releaseLock(for: store1)
|
|
}
|
|
|
|
// Task for store2
|
|
group.addTask {
|
|
await RefreshLockManager.shared.acquireLock(for: store2)
|
|
incrementConcurrent()
|
|
try? await Task.sleep(nanoseconds: 50_000_000) // 50ms
|
|
decrementConcurrent()
|
|
await RefreshLockManager.shared.releaseLock(for: store2)
|
|
}
|
|
}
|
|
|
|
// Both stores should have been able to hold locks concurrently
|
|
#expect(maxConcurrent == 2, "Expected both stores to hold locks concurrently, but max concurrent was \(maxConcurrent)")
|
|
}
|
|
|
|
@Test("Should handle high contention stress test")
|
|
func stressTestHighContention() async {
|
|
let store = MockTokenStore()
|
|
let taskCount = 50
|
|
var executionOrder: [Int] = []
|
|
let orderLock = NSLock()
|
|
|
|
func appendOrder(_ n: Int) {
|
|
orderLock.lock()
|
|
executionOrder.append(n)
|
|
orderLock.unlock()
|
|
}
|
|
|
|
// Launch 50 concurrent tasks all fighting for the same lock
|
|
await withTaskGroup(of: Void.self) { group in
|
|
for i in 1...taskCount {
|
|
group.addTask {
|
|
await RefreshLockManager.shared.acquireLock(for: store)
|
|
appendOrder(i * 10) // Entry
|
|
// Brief work while holding lock
|
|
try? await Task.sleep(nanoseconds: 1_000_000) // 1ms
|
|
appendOrder(i * 10 + 1) // Exit
|
|
await RefreshLockManager.shared.releaseLock(for: store)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Should have 100 events (50 entries + 50 exits)
|
|
#expect(executionOrder.count == taskCount * 2, "Expected \(taskCount * 2) events, got \(executionOrder.count)")
|
|
|
|
// Verify serialization - no interleaving under high contention
|
|
var inProgress: Int? = nil
|
|
var interleaveCount = 0
|
|
for event in executionOrder {
|
|
let taskId = event / 10
|
|
let isEntry = event % 10 == 0
|
|
|
|
if isEntry {
|
|
if inProgress != nil {
|
|
interleaveCount += 1
|
|
}
|
|
inProgress = taskId
|
|
} else {
|
|
if inProgress != taskId {
|
|
interleaveCount += 1
|
|
}
|
|
inProgress = nil
|
|
}
|
|
}
|
|
|
|
#expect(interleaveCount == 0, "Found \(interleaveCount) interleaving violations under high contention - LOCK BUG!")
|
|
}
|
|
|
|
@Test("Should wake all waiters when lock is released and serialize their acquisition")
|
|
func wakeAllWaitersAndSerialize() async {
|
|
let store = MockTokenStore()
|
|
var executionOrder: [Int] = []
|
|
let orderLock = NSLock()
|
|
|
|
func appendOrder(_ n: Int) {
|
|
orderLock.lock()
|
|
executionOrder.append(n)
|
|
orderLock.unlock()
|
|
}
|
|
|
|
// First task acquires lock and holds it
|
|
await RefreshLockManager.shared.acquireLock(for: store)
|
|
|
|
// Start 3 tasks that will all wait for the lock
|
|
let waitingTasks = Task {
|
|
await withTaskGroup(of: Void.self) { group in
|
|
for i in 1...3 {
|
|
group.addTask {
|
|
await RefreshLockManager.shared.acquireLock(for: store)
|
|
appendOrder(i * 10) // Record entry: 10, 20, or 30
|
|
// Hold the lock briefly to ensure we'd see interleaving if bug exists
|
|
try? await Task.sleep(nanoseconds: 10_000_000) // 10ms
|
|
appendOrder(i * 10 + 1) // Record exit: 11, 21, or 31
|
|
await RefreshLockManager.shared.releaseLock(for: store)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Give tasks time to start waiting (all 3 should be blocked)
|
|
try? await Task.sleep(nanoseconds: 50_000_000) // 50ms
|
|
|
|
// Release the lock - all waiters wake up, but only ONE should acquire
|
|
await RefreshLockManager.shared.releaseLock(for: store)
|
|
|
|
// Wait for all tasks to complete
|
|
await waitingTasks.value
|
|
|
|
// All 3 waiting tasks should have completed
|
|
#expect(executionOrder.count == 6, "Expected 6 events (3 entries + 3 exits), got \(executionOrder.count)")
|
|
|
|
// CRITICAL: Verify no interleaving - this catches the while vs if bug
|
|
// If bug exists, multiple waiters acquire lock simultaneously after being resumed
|
|
var inProgress: Int? = nil
|
|
for event in executionOrder {
|
|
let taskId = event / 10
|
|
let isEntry = event % 10 == 0
|
|
|
|
if isEntry {
|
|
#expect(inProgress == nil, "Task \(taskId) entered while task \(inProgress ?? -1) was in progress - LOCK BUG!")
|
|
inProgress = taskId
|
|
} else {
|
|
#expect(inProgress == taskId, "Task \(taskId) exited but task \(inProgress ?? -1) was in progress")
|
|
inProgress = nil
|
|
}
|
|
}
|
|
}
|
|
}
|