<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Ios on Lessons learned</title><link>https://eghuro.cz/tags/ios/</link><description>Recent content in Ios on Lessons learned</description><generator>Hugo</generator><language>en-us</language><copyright>© Alexandr Mansurov · Licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)</copyright><lastBuildDate>Tue, 03 Nov 2026 18:00:00 +0100</lastBuildDate><atom:link href="https://eghuro.cz/tags/ios/index.xml" rel="self" type="application/rss+xml"/><item><title>Offline login in an iOS or Android app: design it as offline unlock</title><link>https://eghuro.cz/posts/offline-login-is-offline-unlock/</link><pubDate>Tue, 03 Nov 2026 18:00:00 +0100</pubDate><guid>https://eghuro.cz/posts/offline-login-is-offline-unlock/</guid><description>&lt;p&gt;A product request for &amp;ldquo;offline login&amp;rdquo; in a mobile app with a cache of sensitive data (customer invoices, HR records, patient notes, credentials) sounds like one feature. On a phone with no network, nothing can check the password against the server, apply MFA, or learn that the device was revoked yesterday. What the app can do is prove a local factor to a device that was enrolled earlier and release keys for ciphertext already on it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;offline login: authenticate the user, apply policy, authorize data (impossible offline)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;offline unlock: prove a local factor, release keys for cached data (what you can build)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I researched how to build the second one on iOS and Android. This post collects the parts that are easy to get wrong: where the password path needs a hardware key, why a biometric prompt without a &lt;code&gt;CryptoObject&lt;/code&gt; is only a boolean check, which clock keeps counting while the phone sleeps, and what changes for users who have no password at all. It is longer than a usual post here, because the traps sit in different layers.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-an-offline-unlock-can-and-cannot-prove"&gt;What an offline unlock can and cannot prove &lt;/h2&gt;&#10;&lt;p&gt;A connected sign-in lets the server authenticate the user and the device, require MFA, evaluate current policy and revocation, and send newly authorized data. An offline unlock does none of that. So it must never renew a server session, refresh authorization, or make cached data look current. Label the action &amp;ldquo;Unlock&amp;rdquo; in the UI and keep &amp;ldquo;Sign in&amp;rdquo; for the server. Show &amp;ldquo;Last checked&amp;rdquo; (policy) and &amp;ldquo;Last synchronized&amp;rdquo; (data) separately, because a recent local unlock is neither.&lt;/p&gt;&#10;&lt;p&gt;The trade-off between availability and revocation is unavoidable. While the device is disconnected, a revocation event cannot reach it, and plaintext it already showed cannot be recalled. A long offline window helps users during outages and travel. It also lets a lost or deauthorized device keep unlocking its cache until the window expires or the device reconnects. Pick a maximum offline age per threat model, make it visible, and apply revocation before any sync or key release at the next connection. Managed users should get an administrator-set finite limit. If you offer an unlimited consumer mode, tell the user that remote revocation and policy changes wait for the next reconnect.&lt;/p&gt;&#10;&lt;p&gt;First use of a device is always a connected sign-in. It enrolls the device, downloads the encrypted state, and creates the local unlock material. A device that was never provisioned online must never create offline capability, and a network error must never let you skip a required connected step.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-state-machine-where-a-stale-device-never-prompts"&gt;A state machine where a stale device never prompts &lt;/h2&gt;&#10;&lt;p&gt;Write the states down, so a missing transition shows up in review:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;UNENROLLED&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └─ connected sign-in + MFA + policy + initial sync ─▶ LOCKED_CURRENT&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;LOCKED_CURRENT&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ freshness valid, then valid local factor ────────▶ UNLOCKED&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ successful check-in ─────────────────────────────▶ LOCKED_CURRENT (freshness renewed)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ freshness expires ───────────────────────────────▶ LOCKED_STALE&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ check-in reports device/user revocation ─────────▶ UNENROLLED&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └─ logout/user removal ─────────────────────────────▶ UNENROLLED&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;UNLOCKED&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ timeout/background/device lock/manual lock ──────▶ LOCKED_CURRENT&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ successful check-in ─────────────────────────────▶ UNLOCKED (freshness renewed)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ freshness expires ───────────────────────────────▶ LOCKED_STALE&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ check-in reports device/user revocation ─────────▶ UNENROLLED&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └─ logout/user removal ─────────────────────────────▶ UNENROLLED&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;LOCKED_STALE&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ successful connected reauthentication/check-in ──▶ LOCKED_CURRENT&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ check-in reports device/user revocation ─────────▶ UNENROLLED&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └─ logout/user removal ─────────────────────────────▶ UNENROLLED&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A check-in is any authenticated server call that returns current policy, revocation state, and server time. A background call with a refresh token is enough. If policy also wants full reauthentication with MFA after some maximum age, put that in the policy record, not in app logic.&lt;/p&gt;&#10;&lt;p&gt;Evaluate freshness before asking for a factor. A stale device shows the reconnect screen and never runs the password KDF, a PIN check, or a biometric prompt. When the window has expired, a biometric prompt that is bound to fail, followed by a generic &amp;ldquo;wrong password&amp;rdquo; error, tells the user nothing about the fix.&lt;/p&gt;&#10;&lt;p&gt;Losing one local factor does not change state. When a biometric wrapper is invalidated, or the PIN hits its threshold, delete that wrapper and continue with the others.&lt;/p&gt;&#10;&#10;&lt;h2 id="wrap-keys-never-encrypt-data-with-the-password"&gt;Wrap keys; never encrypt data with the password &lt;/h2&gt;&#10;&lt;p&gt;Use envelope encryption:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Generate a random 256-bit account data key (ADK).&lt;/li&gt;&#10;&lt;li&gt;Generate a random key per record and per file and wrap each under the ADK. Do not derive them from the ADK. A derived key can always be derived again, so you cannot destroy or rotate it for one object without rotating the ADK.&lt;/li&gt;&#10;&lt;li&gt;Encrypt record fields, search indexes, file metadata, thumbnails, and file content with an AEAD: AES-256-GCM or ChaCha20-Poly1305.&lt;/li&gt;&#10;&lt;li&gt;Wrap the ADK once per unlock method: password, biometric, optional PIN, recovery key.&lt;/li&gt;&#10;&lt;li&gt;Encrypt the password and PIN wrappers a second time under a non-exportable hardware key that needs no user authentication: Secure Enclave on iOS, Android Keystore on Android.&lt;/li&gt;&#10;&lt;li&gt;Put version, algorithm, KDF parameters, key ID, account, object ID, field ID, and purpose into the authenticated data.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;The field ID in step 6 looks like overkill until a field moves. If an attacker can swap the bank-account field of supplier record A with that of record B and the AEAD still verifies, the app shows a payment destination nobody entered for that supplier. In an autofill provider, the same swap between URL fields offers entry A&amp;rsquo;s password to site B.&lt;/p&gt;&#10;&lt;p&gt;A whole-database encryption layer can hide metadata and WAL contents. It does not replace per-field AEAD, because it usually protects only while the database is closed.&lt;/p&gt;&#10;&#10;&lt;h3 id="why-the-password-path-needs-the-device-too"&gt;Why the password path needs the device too &lt;/h3&gt;&#10;&lt;p&gt;The threat model that changes the design is the attacker with a copied sandbox or backup. Assume they hold the database, salts, KDF parameters, wrapped keys, and preferences. If the password wrapper is only &lt;code&gt;KDF(password)&lt;/code&gt;, they can run the KDF on GPUs, away from the device, for as long as they like.&lt;/p&gt;&#10;&lt;p&gt;Step 5 fixes that. With the wrapper sealed by a device key that never leaves the Secure Enclave or Keystore, every guess has to run on the original phone, at its speed. The KDF becomes the second barrier, not the only one. A secret stored in plaintext next to the copied files is not a second factor and does not count as one.&lt;/p&gt;&#10;&lt;p&gt;For the KDF itself:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Prefer Argon2id with a random salt and versioned parameters, benchmarked on the weakest supported device. It is not part of CryptoKit, CommonCrypto, or the standard Android crypto APIs, so it is a third-party dependency with its own review, pinning, and update process.&lt;/li&gt;&#10;&lt;li&gt;If you have to use PBKDF2, both platforms ship it: &lt;code&gt;CCKeyDerivationPBKDF&lt;/code&gt; and &lt;code&gt;SecretKeyFactory.getInstance(&amp;quot;PBKDF2WithHmacSHA256&amp;quot;)&lt;/code&gt;. Calibrate the iteration count on real devices instead of copying one from another project.&lt;/li&gt;&#10;&lt;li&gt;Store the parameters with the envelope and migrate them after a successful unlock.&lt;/li&gt;&#10;&lt;li&gt;Derive separate keys for local wrapping and for any protocol authentication, with domain separation.&lt;/li&gt;&#10;&lt;li&gt;On iOS, hold keys in CryptoKit &lt;code&gt;SymmetricKey&lt;/code&gt;, which zeroes its storage on release, not in &lt;code&gt;Data&lt;/code&gt; that the runtime may copy. A password captured as a Swift &lt;code&gt;String&lt;/code&gt; cannot be wiped; turn it into bytes for the KDF right away.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;KDF cost does not stop a patient person typing guesses into the unmodified app, so add an increasing delay and a persisted failure counter, and require a connected sign-in after a threshold.&lt;/p&gt;&#10;&#10;&lt;h3 id="a-six-digit-pin-is-a-million-candidates"&gt;A six-digit PIN is a million candidates &lt;/h3&gt;&#10;&lt;p&gt;An app PIN is a convenience secret in front of the password. It unlocks its own envelope, and that envelope has to be bound to a non-exportable platform key. Six digits are 10^6 candidates. An unbound PIN envelope falls to offline search, whatever the KDF costs.&lt;/p&gt;&#10;&lt;p&gt;The rest of the PIN rules:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;At least six digits; off by default for high-assurance or managed profiles.&lt;/li&gt;&#10;&lt;li&gt;Never store a PIN verifier that is cheaper to attack than the envelope.&lt;/li&gt;&#10;&lt;li&gt;A failure counter scoped to the user that survives process death and reboot, with increasing delay and at most 10 consecutive failures, the upper bound NIST SP 800-63B sets for activation secrets.&lt;/li&gt;&#10;&lt;li&gt;At the threshold, delete the PIN wrapper and ask for the password. Wiping the local data would destroy the only copy of anything not yet synced.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The catch: neither iOS nor Android enforces a retry limit in hardware for an app-defined PIN. NIST&amp;rsquo;s limit assumes the authenticator enforces it, and here the app does. The counter stops the user and a thief using the unmodified app. On a rooted or jailbroken device, an attacker can query the platform key directly at hardware speed, and only PIN length helps against that.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-biometric-prompt-must-unlock-a-key"&gt;The biometric prompt must unlock a key &lt;/h2&gt;&#10;&lt;p&gt;Biometric success has to authorize the cryptographic operation that unwraps the ADK. If it only flips an &lt;code&gt;isAuthenticated&lt;/code&gt; flag, a hooked callback on a rooted device skips it.&lt;/p&gt;&#10;&#10;&lt;h3 id="android-pass-a-cryptoobject"&gt;Android: pass a &lt;code&gt;CryptoObject&lt;/code&gt; &lt;/h3&gt;&#10;&lt;p&gt;Generate the key inside Keystore and require authentication for every use:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;spec&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;KeyGenParameterSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;offline-data-wrap&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nc"&gt;KeyProperties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PURPOSE_ENCRYPT&lt;/span&gt; &lt;span class="n"&gt;or&lt;/span&gt; &lt;span class="nc"&gt;KeyProperties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PURPOSE_DECRYPT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setKeySize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setBlockModes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;KeyProperties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BLOCK_MODE_GCM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setEncryptionPaddings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;KeyProperties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ENCRYPTION_PADDING_NONE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setUserAuthenticationRequired&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setUserAuthenticationParameters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;KeyProperties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AUTH_BIOMETRIC_STRONG&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// API 30+: every use prompts&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setInvalidatedByBiometricEnrollment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setUnlockedDeviceRequired&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// API 28+&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setIsStrongBoxBacked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// API 28+; on StrongBoxUnavailableException, retry in the TEE&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nc"&gt;KeyGenerator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;getInstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;KeyProperties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;KEY_ALGORITHM_AES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;AndroidKeyStore&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;apply&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;generateKey&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then hand the initialized cipher to &lt;code&gt;BiometricPrompt&lt;/code&gt;, and decrypt only with the cipher you get back:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;cipher&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;getInstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;AES/GCM/NoPadding&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DECRYPT_MODE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wrappingKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;GCMParameterSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;KeyPermanentlyInvalidatedException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;deleteBiometricWrapper&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// enrolled biometrics changed: fall back to the password&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;prompt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;BiometricPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;object&lt;/span&gt; &lt;span class="err"&gt;: &lt;/span&gt;&lt;span class="nc"&gt;BiometricPrompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AuthenticationCallback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;onAuthenticationSucceeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;BiometricPrompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AuthenticationResult&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;unlocked&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cryptoObject&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cipher&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;unlocked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updateAAD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aad&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;adk&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;unlocked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;doFinal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ciphertext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// ...&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;})&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;authenticate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nc"&gt;BiometricPrompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PromptInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Unlock offline data&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setAllowedAuthenticators&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BiometricManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Authenticators&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BIOMETRIC_STRONG&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setNegativeButtonText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Use password&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nc"&gt;BiometricPrompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CryptoObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Set the accepted authenticators explicitly. A default that includes &lt;code&gt;DEVICE_CREDENTIAL&lt;/code&gt; means the device PIN opens the offline data, and so does anyone who watched the owner type it. If you want that fallback, make it a written product decision. Since this is a symmetric key with authentication required, creating the wrapper at enrollment also needs a prompt; ask for the password first, as with any new factor.&lt;/p&gt;&#10;&#10;&lt;h3 id="ios-a-secure-enclave-key-and-ecies"&gt;iOS: a Secure Enclave key and ECIES &lt;/h3&gt;&#10;&lt;p&gt;Generate the wrapping key in the Secure Enclave with biometric access control, and wrap the ADK with ECIES:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Unmanaged&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CFError&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;?&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;access&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SecAccessControlCreateWithFlags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// the OS deletes it if the passcode is removed&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;[.&lt;/span&gt;&lt;span class="n"&gt;privateKeyUsage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;biometryCurrentSet&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;// no passcode fallback&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;kSecAttrKeyType&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;kSecAttrKeyTypeECSECPrimeRandom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;kSecAttrKeySizeInBits&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;kSecAttrTokenID&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;kSecAttrTokenIDSecureEnclave&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;kSecPrivateKeyAttrs&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;kSecAttrIsPermanent&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;kSecAttrApplicationTag&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;offline-data-wrap&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utf8&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;kSecAttrAccessControl&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;access&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;privateKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SecKeyCreateRandomKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;CFDictionary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;wrapped&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SecKeyCreateEncryptedData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;SecKeyCopyPublicKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;privateKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;eciesEncryptionCofactorX963SHA256AESGCM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;adk&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;CFData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;To unwrap, fetch the key with a fresh &lt;code&gt;LAContext&lt;/code&gt; passed as &lt;code&gt;kSecUseAuthenticationContext&lt;/code&gt;, with &lt;code&gt;touchIDAuthenticationAllowableReuseDuration&lt;/code&gt; left at 0, and call &lt;code&gt;SecKeyCreateDecryptedData&lt;/code&gt;. The prompt the user sees then authorizes this key use. That is the iOS counterpart of Android&amp;rsquo;s per-use authentication.&lt;/p&gt;&#10;&lt;p&gt;Two details to get right:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;The accessibility class goes into &lt;code&gt;SecAccessControlCreateWithFlags&lt;/code&gt;. It cannot be combined with a separate &lt;code&gt;kSecAttrAccessible&lt;/code&gt; attribute on the same item.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;.biometryCurrentSet&lt;/code&gt; invalidates the key when Face ID or Touch ID enrollment changes. Handle that by deleting the wrapper and requiring the password. A &lt;code&gt;.devicePasscode&lt;/code&gt; or &lt;code&gt;.userPresence&lt;/code&gt; fallback lets anyone who knows the device passcode into the offline data.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="data-protection-classes-that-fail-after-lock"&gt;Data Protection classes that fail after lock &lt;/h2&gt;&#10;&lt;p&gt;Set a Data Protection class per store instead of relying on container defaults. The &lt;code&gt;complete&lt;/code&gt; class key is discarded about 10 seconds after the device locks, which bites in three places:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Plaintext temporaries should use &lt;code&gt;complete&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Background downloads of offline files need &lt;code&gt;completeUnlessOpen&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;A SQLite database in WAL mode under &lt;code&gt;complete&lt;/code&gt; fails when anything touches it after lock. Either stop database access on &lt;code&gt;protectedDataWillBecomeUnavailable&lt;/code&gt;, or use &lt;code&gt;completeUntilFirstUserAuthentication&lt;/code&gt; for the database and rely on field-level AEAD.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Files in &lt;code&gt;tmp/&lt;/code&gt; and elsewhere in the container default to &lt;code&gt;completeUntilFirstUserAuthentication&lt;/code&gt; unless you set otherwise.&lt;/p&gt;&#10;&#10;&lt;h2 id="offline-expiry-needs-a-clock-that-counts-sleep"&gt;Offline expiry needs a clock that counts sleep &lt;/h2&gt;&#10;&lt;p&gt;Enforcing maximum offline age with wall-clock time alone fails the moment someone sets the clock back. I covered that half in &lt;a&#10; href="https://eghuro.cz/posts/offline-cache-clock-rollback"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;An offline cache TTL needs a clock-rollback policy&lt;/a&gt;&#10;. The research added three things.&lt;/p&gt;&#10;&lt;p&gt;The freshness record has two parts. A server-signed part (check-in time, policy version, expiry duration), verified with the server&amp;rsquo;s public key. A local part (wall time at check-in, monotonic time at check-in, boot reference, accumulated elapsed time), authenticated with a keystore device key. Do not protect it with the ADK: freshness is evaluated before accepting any factor, so the record must verify while the app is locked.&lt;/p&gt;&#10;&lt;p&gt;The monotonic clock must keep counting while the phone sleeps, because a phone is asleep most of the time:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Platform&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Use&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Stops during sleep&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;iOS&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;mach_continuous_time()&lt;/code&gt;, &lt;code&gt;clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW)&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;mach_absolute_time()&lt;/code&gt;, &lt;code&gt;ProcessInfo.systemUptime&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Android&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;SystemClock.elapsedRealtime()&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;SystemClock.uptimeMillis()&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Verify the chosen clock on a device by letting it sleep.&lt;/p&gt;&#10;&lt;p&gt;Reboots reset the monotonic clock. Android has &lt;code&gt;Settings.Global.BOOT_COUNT&lt;/code&gt;. iOS has no documented boot identifier: &lt;code&gt;kern.boottime&lt;/code&gt; moves when the user changes the wall clock, so it detects clock changes, not boots. On iOS, detect a reboot by a monotonic reading lower than the stored one. Then:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;update accumulated elapsed time on every foreground, unlock, and lock&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;same boot: elapsed from the monotonic clock, cross-checked against wall time&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;after reboot: elapsed = max(accumulated + monotonic_since_boot,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; wall_now - wall_at_checkin)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;wall_now &amp;lt; last stored wall time → rollback → stale&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A rollback across a reboot can then hide at most the time between the last update and the reboot.&lt;/p&gt;&#10;&lt;p&gt;Fail closed on a missing, rolled-back, or inconsistent record, and exclude it from backup. A restore, to another device or to the same one, then leaves the record missing and forces a check-in. Otherwise, a same-device restore brings back an older valid record together with the &lt;code&gt;ThisDeviceOnly&lt;/code&gt; Keychain items it belongs to.&lt;/p&gt;&#10;&lt;p&gt;These controls resist clock rollback, not state rollback. Nothing stops someone from replacing the current record with an older valid copy, and the expiry is application logic. Like the PIN counter, it binds the unmodified app, not an attacker running their own code against the device keys.&lt;/p&gt;&#10;&#10;&lt;h2 id="documents-chunked-aead-and-plaintext-that-outlives-the-app"&gt;Documents: chunked AEAD and plaintext that outlives the app &lt;/h2&gt;&#10;&lt;p&gt;Offline documents, such as invoice PDFs or scanned contracts, need more than per-record encryption. CryptoKit&amp;rsquo;s &lt;code&gt;AES.GCM&lt;/code&gt; and &lt;code&gt;ChaChaPoly&lt;/code&gt; seal a whole message in memory. Streaming playback and seeking viewers need chunks, and naive chunking lets an attacker truncate, reorder, or drop chunks without failing authentication. Use a format in the style of the STREAM construction:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;fixed-size chunks (say 64 KiB), each sealed separately;&lt;/li&gt;&#10;&lt;li&gt;a nonce built from a random per-file prefix and the chunk index;&lt;/li&gt;&#10;&lt;li&gt;file ID, format version, chunk index, and a final-chunk flag in each chunk&amp;rsquo;s authenticated data;&lt;/li&gt;&#10;&lt;li&gt;an authenticated header with the prefix, chunk size and version;&lt;/li&gt;&#10;&lt;li&gt;a new per-file key or nonce prefix whenever the file changes, so no key and nonce pair is ever reused.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Prefer decrypting into memory or a pipe for an in-app viewer. &lt;code&gt;QLPreviewController&lt;/code&gt; wants a file URL, so some formats force a plaintext file. When one is unavoidable, put it in a dedicated private directory under &lt;code&gt;complete&lt;/code&gt;, with an unpredictable name, excluded from backup and indexing, shared only through a narrow, non-exported &lt;code&gt;FileProvider&lt;/code&gt; grant on Android.&lt;/p&gt;&#10;&lt;p&gt;The trap is cleanup. A suspended iOS app runs no code, so no timer deletes the file on schedule, and cancellation, render errors, crashes, process death, and reboot all skip view-level cleanup. Make the launch path enumerate and delete expired temporaries before it allows an unlock.&lt;/p&gt;&#10;&#10;&lt;h2 id="lock-logout-and-keychain-items-that-survive-uninstall"&gt;Lock, logout, and Keychain items that survive uninstall &lt;/h2&gt;&#10;&lt;p&gt;Lock keeps the encrypted state and destroys the usable keys: release the ADK and derived keys, close plaintext streams, blank the app-switcher snapshot, invalidate decrypted caches, and stop background jobs from quietly recreating an unlocked session. A suspended app cannot clear the clipboard later, so set the expiry when you copy:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;UIPasteboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;general&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setItems&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="n"&gt;UTType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utf8PlainText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[.&lt;/span&gt;&lt;span class="n"&gt;localOnly&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expirationDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;addingTimeInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;.localOnly&lt;/code&gt; keeps it off Universal Clipboard. On Android, mark the clip with &lt;code&gt;ClipDescription.EXTRA_IS_SENSITIVE&lt;/code&gt; (API 33+) and clear it with &lt;code&gt;ClipboardManager.clearPrimaryClip()&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Logout makes the state permanently unusable. Physical overwrite on flash is unreliable, so the primary boundary is cryptographic erasure: destroy every usable wrapping key, then delete tokens, databases with their WAL and journal files, indexes, cached files, temporaries, pending operations, and shared-container state.&lt;/p&gt;&#10;&lt;p&gt;On iOS, uninstalling the app deletes its container but leaves its Keychain items, and no app code runs during uninstall. Keep a marker file in the container. When it is missing at launch, delete every Keychain item the app owns, shared access groups included, before doing anything else. Otherwise, a reinstall can find the previous install&amp;rsquo;s wrappers.&lt;/p&gt;&#10;&#10;&lt;h2 id="autofill-is-offline-unlock-under-another-name"&gt;Autofill is offline unlock under another name &lt;/h2&gt;&#10;&lt;p&gt;If the cached data includes credentials and the app offers an autofill provider, that provider releases secrets from the local cache with no server call, so every rule above applies to it. The platforms constrain it differently.&lt;/p&gt;&#10;&lt;p&gt;On iOS, the Credential Provider extension is a separate process with its own unlock session. It sees the app&amp;rsquo;s state only through a shared Keychain access group and an App Group container, so every key and wrapper it needs, including the Secure Enclave device key and the freshness record, must live in the shared group. It runs under a memory limit Apple describes as only slightly lower than an app&amp;rsquo;s. That limit caps the Argon2id memory parameter, so benchmark inside the extension on the weakest device. Do not give the extension a cheaper copy of the password envelope: the weakest wrapper sets the strength of all the data it protects. If parameters that fit are too weak, let the extension offer biometrics only and send password users to the app.&lt;/p&gt;&#10;&lt;p&gt;An &lt;code&gt;LAContext&lt;/code&gt; evaluated in the app does not carry over to the extension. Within one request, the extension can pass one evaluated context to several Keychain reads, so a single prompt covers a list. The system may reuse the extension process for the next request, so nothing decrypted may stay in a static.&lt;/p&gt;&#10;&lt;p&gt;On Android, the &lt;code&gt;AutofillService&lt;/code&gt; runs in the app&amp;rsquo;s own process unless the manifest says otherwise. Its authentication activity still needs a &lt;code&gt;CryptoObject&lt;/code&gt;, and it should accept the same authenticators as the app.&lt;/p&gt;&#10;&lt;p&gt;Rules for both:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Give the provider its own key that wraps only what a fill needs (username, password, URL), so a faulty extension cannot decrypt documents, notes, or indexes.&lt;/li&gt;&#10;&lt;li&gt;Share the failure counter between app and provider. Otherwise, an attacker doubles the allowed attempts by alternating between them.&lt;/li&gt;&#10;&lt;li&gt;The provider reads freshness and never checks it itself. When stale, it fills nothing and says which app to open.&lt;/li&gt;&#10;&lt;li&gt;On iOS, the identity store shows registered usernames next to their domains in the QuickType bar to anyone holding the unlocked phone, with the app locked. Replace the whole identity set after each sync instead of removing and re-adding the current ones, so deleted entries disappear, and consider a managed policy that registers none.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h3 id="verify-who-is-asking"&gt;Verify who is asking &lt;/h3&gt;&#10;&lt;p&gt;The requester check is where autofill phishing happens (&lt;a&#10; href="https://doi.org/10.1145/3243734.3243778"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Aonzo et al., &lt;em&gt;Phishing Attacks on Modern Android&lt;/em&gt;, CCS 2018&lt;/a&gt;&#10;). iOS identifies websites and apps through associated domains. On Android, the provider has to do it:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Verify the requesting app&amp;rsquo;s signing certificate against the domain&amp;rsquo;s &lt;a&#10; href="https://developers.google.com/digital-asset-links"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Digital Asset Links&lt;/a&gt;&#10;, not its package name. An app named &lt;code&gt;com.examplebank.evil&lt;/code&gt; is not Example Bank.&lt;/li&gt;&#10;&lt;li&gt;Trust browsers by package name and certificate digest together. On a device without a listed browser, a sideloaded app can take that browser&amp;rsquo;s package name and report any web domain.&lt;/li&gt;&#10;&lt;li&gt;Derive registrable domains from the &lt;a&#10; href="https://publicsuffix.org"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Public Suffix List&lt;/a&gt;&#10;, not a hand-written list of suffixes. Otherwise, an entry saved for &lt;code&gt;victim.github.io&lt;/code&gt; collapses to &lt;code&gt;github.io&lt;/code&gt; and fills in &lt;code&gt;attacker.github.io&lt;/code&gt;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="users-without-a-password"&gt;Users without a password &lt;/h2&gt;&#10;&lt;p&gt;Everything above assumes every user has a password. Users who sign in through enterprise SSO or a partner&amp;rsquo;s authenticator app do not. In this design, the password has five jobs:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;the baseline wrapper of the ADK;&lt;/li&gt;&#10;&lt;li&gt;the fallback when a biometric wrapper is invalidated;&lt;/li&gt;&#10;&lt;li&gt;the authorization for a new biometric wrapper;&lt;/li&gt;&#10;&lt;li&gt;the replacement when the PIN hits its threshold;&lt;/li&gt;&#10;&lt;li&gt;the recent password entry that sensitive actions require.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Each job needs a new owner. The options I found:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Connected sign-in takes every fallback role. This one always applies. A user who loses their last local factor while offline has no offline access until they reconnect.&lt;/li&gt;&#10;&lt;li&gt;A mandatory app passcode becomes the baseline factor. Set it during connected provisioning and wrap the ADK with it like the password (KDF, then device key). Three PIN rules change. Six digits stop being enough, because nothing stands behind the passcode: at 0.5 seconds per guess on a compromised device, 10^6 candidates take under six days. Allow alphanumeric passcodes with a real minimum length. The threshold sends the user to connected sign-in, and choosing a new passcode needs one too. The user gets a second secret to remember; that is the price of an offline knowledge factor.&lt;/li&gt;&#10;&lt;li&gt;The device passcode can back up the biometric wrapper. It avoids losing offline access at the first biometric change and reopens the &amp;ldquo;watched the owner type it&amp;rdquo; risk. Make it explicit, let administrators forbid it, and check on a device whether iOS still releases a &lt;code&gt;biometryCurrentSet&lt;/code&gt; or &lt;code&gt;devicePasscode&lt;/code&gt; item through the passcode after enrollment changes.&lt;/li&gt;&#10;&lt;li&gt;A recovery key can have enough entropy that a copied wrapper is safe even without device binding. Users lose these, and nobody can reset one, so offer it only where a lost factor can cost data.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;What does not work is anything the identity provider issued. A cached refresh token or ID token proves the device holds a token, not that the user is holding the device. Don&amp;rsquo;t wrap the ADK under it, and delete the identity provider&amp;rsquo;s token cache after the server exchange.&lt;/p&gt;&#10;&lt;p&gt;Check one thing in your own protocol. If the server opens a passwordless user&amp;rsquo;s data without any secret from the user, it can provision the ADK again at the next sign-in. A lost factor then costs offline access, not data, as long as offline records stay read-only. Unsynced offline edits are the exception, because they exist only on the device.&lt;/p&gt;&#10;&lt;p&gt;Decide the factor set at provisioning from the login method, and store it with the policy. If the same user later provisions through a password login, re-enroll; don&amp;rsquo;t bolt a password wrapper onto a passwordless enrollment.&lt;/p&gt;&#10;&#10;&lt;h2 id="caveats"&gt;Caveats &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;The ADK&amp;rsquo;s scope is a protocol decision. If the ADK only protects this device&amp;rsquo;s cache, logout and revocation destroy it. If it is the user&amp;rsquo;s end-to-end key shared with the server and other devices, a device revoked while offline keeps a copy that decrypts future data, and revoking it means rotating the key.&lt;/li&gt;&#10;&lt;li&gt;Offline edits are a separate project. Ship read-only offline records first. Editable mode needs an encrypted operation log separate from server-confirmed state, conflict rules that keep both versions when a merge could lose data, and an authorization recheck for each operation on reconnect.&lt;/li&gt;&#10;&lt;li&gt;On rooted and jailbroken devices, hardware-backed keys and integrity signals raise the cost. Retry counters and expiry are app logic and don&amp;rsquo;t bind an attacker who runs their own code against the keys. Don&amp;rsquo;t claim otherwise.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="test-the-failure-paths"&gt;Test the failure paths &lt;/h2&gt;&#10;&lt;p&gt;Happy-path testing says nothing about whether keys and plaintext stay contained. The release gates I would not skip:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Area&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Test&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Provisioning&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;A never-enrolled device fails offline; restoring a backup to another device, or an older backup to the same one, forces a check-in&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Crypto&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Copied password and PIN wrappers cannot be opened off the device; ciphertext swapped between records or between fields of one record fails authentication&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Biometrics&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Add and remove enrolled biometrics, change and remove the passcode, reboot, process death&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Retries&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Counters survive relaunch and reboot; the PIN threshold removes only the PIN wrapper&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Freshness&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Clock forward and back, time zone change, reboot, a long sleep counted toward expiry, a replayed older record, the exact boundary; a stale device never prompts&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Documents&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Truncated, reordered, and dropped chunks fail; kill the app mid-preview and confirm the temporary is gone before the next unlock&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Deletion&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Uninstall and reinstall leave no usable Keychain state; removing one user leaves another intact&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Autofill origin&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;A look-alike package name gets no match; &lt;code&gt;victim.github.io&lt;/code&gt; is not offered on &lt;code&gt;attacker.github.io&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;&lt;a&#10; href="https://mas.owasp.org/MASVS"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP MASVS&lt;/a&gt;&#10; and &lt;a&#10; href="https://mas.owasp.org/MASTG"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;MASTG&lt;/a&gt;&#10;, &lt;a&#10; href="https://pages.nist.gov/800-63-4/sp800-63b.html"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;NIST SP 800-63B&lt;/a&gt;&#10;, &lt;a&#10; href="https://csrc.nist.gov/pubs/sp/800/132/final"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;NIST SP 800-132&lt;/a&gt;&#10; for PBKDF2, and &lt;a&#10; href="https://www.rfc-editor.org/rfc/rfc9106"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;RFC 9106&lt;/a&gt;&#10; for Argon2id set the baseline. They don&amp;rsquo;t decide where your keys live or what happens when a preview crashes.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;Offline, the app can only answer &amp;ldquo;is this the enrolled user, on the enrolled device, within the allowed window?&amp;rdquo; Every unlock path, including the password, should require the device&amp;rsquo;s hardware key; every biometric prompt should gate a key operation; and the window should be measured by a clock that keeps counting while the phone sleeps.&lt;/p&gt;&#10;&lt;p&gt;If your app already has an offline mode, copy its sandbox to another machine and try to open the password wrapper there. If that attempt gets as far as a KDF, the password path is missing its device key.&lt;/p&gt;&#10;</description></item><item><title>A shared Swift timeout left an earlier task without a deadline</title><link>https://eghuro.cz/posts/swift-concurrency-shared-timeout/</link><pubDate>Fri, 30 Oct 2026 17:37:00 +0100</pubDate><guid>https://eghuro.cz/posts/swift-concurrency-shared-timeout/</guid><description>&lt;p&gt;A Swift service had a timeout, but waiting for its in-flight work could still hang. A held-fetch regression test reached Swift Testing&amp;rsquo;s outer limit:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Time limit was exceeded: 60.000 seconds&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Starting another check replaced the shared timer while the earlier fetch kept running. The fix was to give each tracked operation its own cancellation deadline and cancel that deadline when the operation ended. That still depends on the fetch cooperating with cancellation.&lt;/p&gt;&#10;&#10;&lt;h2 id="one-timer-could-not-cover-overlapping-fetches"&gt;One timer could not cover overlapping fetches &lt;/h2&gt;&#10;&lt;p&gt;The service could start another asynchronous check before the previous one finished. A separate tracker retained the in-flight tasks so a caller could wait for their completion. Its waiter took a snapshot of those tasks and awaited their results.&lt;/p&gt;&#10;&lt;p&gt;The mistaken assumption was that the service&amp;rsquo;s existing timeout covered everything in that snapshot. Review found that starting a check reset the shared timer without canceling the previous fetch. Finishing a check also reset the timer so that an older completion could interfere with a newer check&amp;rsquo;s deadline.&lt;/p&gt;&#10;&lt;p&gt;An illustrative sequence makes the ownership mismatch visible:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;start A tracked: A shared timer: A&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;start B tracked: A, B shared timer: B; A&amp;#39;s timer reset&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;finish B tracked: A shared timer: reset&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;wait for A A still running no cancellation deadline for A&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The tracker remembered more work than the timer covered. Making that timer longer would leave the same mismatch.&lt;/p&gt;&#10;&#10;&lt;h2 id="put-the-deadline-beside-the-operation"&gt;Put the deadline beside the operation &lt;/h2&gt;&#10;&lt;p&gt;The correction belonged where the tracker created each fetch task. That scope already knew which task it owned and when it finished.&lt;/p&gt;&#10;&lt;p&gt;For each operation, create a separate deadline task that sleeps for the configured duration, then calls &lt;code&gt;cancel()&lt;/code&gt; on that operation&amp;rsquo;s task handle. Keep the deadline handle local. On exit, cancel the deadline and remove the operation from tracking. Forward cancellation from the caller to the fetch as well.&lt;/p&gt;&#10;&lt;p&gt;The resulting ownership is:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;operation A owns deadline A; completion A cancels deadline A&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;operation B owns deadline B; completion B cancels deadline B&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Canceling a deadline&amp;rsquo;s sleep must end the deadline task. Swallowing the sleep&amp;rsquo;s cancellation error and continuing to &lt;code&gt;cancel()&lt;/code&gt; would turn normal cleanup into an unintended cancellation request.&lt;/p&gt;&#10;&lt;p&gt;Keeping the handles local also means A&amp;rsquo;s cleanup cannot accidentally cancel B&amp;rsquo;s deadline through a shared slot.&lt;/p&gt;&#10;&#10;&lt;h2 id="prove-that-the-waiter-needs-this-deadline"&gt;Prove that the waiter needs this deadline &lt;/h2&gt;&#10;&lt;p&gt;The regression test used a fetch held until cancellation, a short injected operation timeout, and a signal confirming that the fetch had started. Nothing outside the tracker canceled it. The test then awaited completion and checked that the fetch ended before the waiting caller continued.&lt;/p&gt;&#10;&lt;p&gt;Before the implementation used the injected timeout, the test failed at the one-minute limit. After adding the operation-local deadline, the archived test output reports the held-fetch test passing and the focused test target exiting successfully.&lt;/p&gt;&#10;&lt;p&gt;That establishes the missing-deadline behavior for a cooperative fake. It does not reproduce the entire overlapping-check sequence live. For your own tracker, also hold A open while B starts and finishes, then confirm that A still receives its own cancellation request.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-cancellation-deadline-is-not-a-hard-timeout"&gt;A cancellation deadline is not a hard timeout &lt;/h2&gt;&#10;&lt;p&gt;&lt;a&#10; href="https://developer.apple.com/documentation/swift/task/cancel%28%29"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Swift&amp;rsquo;s &lt;code&gt;Task.cancel()&lt;/code&gt; documentation&lt;/a&gt;&#10; explicitly says cancellation is cooperative. Calling it flags the task and runs cancellation handlers; arbitrary work does not automatically stop. A fetch that ignores cancellation can outlive this deadline and keep a completion waiter suspended.&lt;/p&gt;&#10;&lt;p&gt;Later work therefore separated the operation&amp;rsquo;s cancellation deadline from the caller&amp;rsquo;s user-facing wait budget. It also added recovery after an unsuccessful check. Giving every operation a deadline did not settle how long a caller should wait or whether continuing after failure was safe.&lt;/p&gt;&#10;&lt;p&gt;Before relying on a service timeout, start two operations and finish the newer one first. Check that the older operation still has its own cancellation deadline.&lt;/p&gt;&#10;</description></item><item><title>SwiftUI large title disappears after tapping the selected tab again</title><link>https://eghuro.cz/posts/swiftui-large-title-global-appearance/</link><pubDate>Fri, 30 Oct 2026 14:38:00 +0100</pubDate><guid>https://eghuro.cz/posts/swiftui-large-title-global-appearance/</guid><description>&lt;p&gt;The Settings large title disappeared after tapping the already-selected tab again. Its space stayed empty; the small title still appeared when scrolling, and the accessibility check passed:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;titleBefore=true titleAfter=true&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;An opaque navigation-bar background was painting over the title. The eventual repair removed a screen modifier&amp;rsquo;s writes to &lt;code&gt;UINavigationBar.appearance()&lt;/code&gt;. Hiding the affected screen&amp;rsquo;s bar background was an intermediate workaround that introduced a font regression.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-title-still-existed"&gt;The title still existed &lt;/h2&gt;&#10;&lt;p&gt;The prototype varied the tab view, loading state, scroll-view wrapper, and swipe-back helper. Its accessibility existence checks stayed green. Adding the app&amp;rsquo;s opaque navigation-bar appearance also produced green checks, even though the title vanished visually.&lt;/p&gt;&#10;&lt;p&gt;Counting dark pixels in the prototype&amp;rsquo;s title region exposed the difference:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Appearance&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: right"&gt;After first tap&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: right"&gt;After re-tap&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Default&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;12955&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;12955&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Opaque&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;12955&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;0&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Those are diagnostic counts from one fixed light-background rendering, not a portable visibility threshold. They contradicted the existence assertion directly.&lt;/p&gt;&#10;&lt;p&gt;Before-and-after hierarchy dumps from the running app showed the same title view moving from the navigation bar into the scroll view. The bar&amp;rsquo;s opaque background then covered it. Initially, the screen displayed a loader without a scroll view, so UIKit built the title inside the bar. Re-tapping the selected tab triggered the transition that exposed the problem on the investigated simulator.&lt;/p&gt;&#10;&lt;p&gt;This establishes a rendering mechanism in that environment. It does not establish identical title placement on every supported runtime.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-local-modifier-changed-later-navigation-bars"&gt;A local modifier changed later navigation bars &lt;/h2&gt;&#10;&lt;p&gt;The offending modifier intended to remove a divider and set one screen&amp;rsquo;s background. Its essential mistake looked like this generalized fragment inside a SwiftUI &lt;code&gt;.onAppear&lt;/code&gt; closure:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;appearance&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;UINavigationBarAppearance&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;appearance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;configureWithOpaqueBackground&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;appearance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;backgroundColor&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;secondarySystemBackground&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;appearance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shadowColor&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clear&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;UINavigationBar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;appearance&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;standardAppearance&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;appearance&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;UINavigationBar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;appearance&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;scrollEdgeAppearance&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;appearance&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The closure belongs to a screen; the &lt;a&#10; href="https://developer.apple.com/documentation/uikit/uiappearance"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;appearance proxy&lt;/a&gt;&#10; configures all instances of that class. UIKit applies appearance changes when a view enters a window. A different tab&amp;rsquo;s later navigation bar can inherit those settings, so screen order becomes part of the bug&amp;rsquo;s setup.&lt;/p&gt;&#10;&lt;p&gt;The repair deleted this global writer. The calling screens already declared their backgrounds through SwiftUI&amp;rsquo;s per-screen toolbar styling; the investigated runtimes did not need a replacement divider-hiding implementation. Their existing snapshots passed without re-recording. Appearance save-and-restore workarounds around a system-presented screen went away with the leak.&lt;/p&gt;&#10;&lt;p&gt;A focused test supplied stronger evidence than the original title lookup: show a styled screen, then create another navigation bar and check its background. It failed before the deletion because the next bar inherited a background, then passed afterward. Separate checks guarded the calling screen&amp;rsquo;s color and the next large title&amp;rsquo;s font.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-the-one-line-hotfix-was-reversed"&gt;Why the one-line hotfix was reversed &lt;/h2&gt;&#10;&lt;p&gt;The initial workaround used &lt;code&gt;.toolbarBackground(.hidden, for: .navigationBar)&lt;/code&gt; on Settings. It kept the prototype&amp;rsquo;s title visible under the opaque appearance.&lt;/p&gt;&#10;&lt;p&gt;The attempted app snapshot never reproduced the original disappearance. The real screen reloaded into a spinner first, unlike the prototype&amp;rsquo;s scroll-view-first case. Capturing the loaded window still showed the title without the fix. We dropped that test; it couldn&amp;rsquo;t serve as a regression guard.&lt;/p&gt;&#10;&lt;p&gt;Later checks found that hiding the background also made the large title use the system font instead of the intended custom font. A snapshot cropped to the bar failed with that modifier present and passed after its removal. Once the global opaque write was gone, the workaround no longer had a reason to stay.&lt;/p&gt;&#10;&#10;&lt;h2 id="verify-the-interaction-not-just-the-label"&gt;Verify the interaction, not just the label &lt;/h2&gt;&#10;&lt;p&gt;Builds and broad test plans passed after the cleanup. Final manual checks in the running app remained open, and older supported runtimes were untested. The font snapshot and next-bar test do not close that gap.&lt;/p&gt;&#10;&lt;p&gt;For release verification:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Visit the screens that previously wrote global appearance, then open Settings. Repeat with a different screen order.&lt;/li&gt;&#10;&lt;li&gt;Tap the selected Settings tab again. Capture the title before and after, then scroll through its large-to-inline transition.&lt;/li&gt;&#10;&lt;li&gt;Check actual title pixels and contrast against the background. Use the hierarchy to explain occlusion; accessibility existence, or &lt;code&gt;isHittable&lt;/code&gt; alone, is not universal proof that text is painted visibly.&lt;/li&gt;&#10;&lt;li&gt;Repeat on supported runtimes, checking pushed screens&amp;rsquo; bar styling too.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Audit screen-level &lt;code&gt;UINavigationBar.appearance()&lt;/code&gt; writes before adding another visibility workaround.&lt;/p&gt;&#10;</description></item><item><title>Xcode Archive can ship the wrong display name despite correct source settings</title><link>https://eghuro.cz/posts/xcode-archive-processed-plist-identity/</link><pubDate>Thu, 29 Oct 2026 14:22:00 +0100</pubDate><guid>https://eghuro.cz/posts/xcode-archive-processed-plist-identity/</guid><description>&lt;p&gt;A production iOS archive had the production bundle ID and backend environment, but its &lt;code&gt;CFBundleDisplayName&lt;/code&gt; was the beta name. The tagged source said otherwise, and App Store Connect never shows that name, so only an installed build revealed it. Add a Run Script phase that checks the processed app &lt;code&gt;Info.plist&lt;/code&gt;, so a manual Product &amp;gt; Archive also rejects the wrong identity.&lt;/p&gt;&#10;&lt;p&gt;Using synthetic names, the mismatch was:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;CFBundleIdentifier = com.example.notebook&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;CFBundleDisplayName = NotebookBeta&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="source-checks-missed-the-artifact"&gt;Source checks missed the artifact &lt;/h2&gt;&#10;&lt;p&gt;The investigation initially ruled out the mismatch from project settings and runtime configuration checks. That conclusion was too strong. The runtime check validated the product variant against the backend environment; it did not validate the display name. A production bundle with a beta display name could satisfy it.&lt;/p&gt;&#10;&lt;p&gt;Reading the archived app&amp;rsquo;s plist settled the question. Its name disagreed with the committed production settings. An uncommitted local edit was the likely explanation, but the archive alone could not establish exactly how the value changed.&lt;/p&gt;&#10;&lt;p&gt;The release flow had no step that showed the name either. App Store Connect metadata covers the store listing&amp;rsquo;s app name, not the bundle&amp;rsquo;s &lt;code&gt;CFBundleDisplayName&lt;/code&gt;, which only appears under the home screen icon. The only check is to install the production build from TestFlight, or from the App Store after release, and read that label by hand. It is easy to overlook when the bundle ID and backend already look right.&lt;/p&gt;&#10;&lt;p&gt;Source-level regression tests still help, but manual Archive does not run the scheme&amp;rsquo;s Test action. A check confined to a deployment lane also misses someone archiving directly in Xcode.&lt;/p&gt;&#10;&#10;&lt;h2 id="check-the-processed-plist-during-the-build"&gt;Check the processed plist during the build &lt;/h2&gt;&#10;&lt;p&gt;Add a Run Script phase named &lt;code&gt;Verify App Identity&lt;/code&gt; to the app target, after its existing phases. Use &lt;code&gt;/bin/sh&lt;/code&gt;. Leave &lt;strong&gt;Run script only when installing&lt;/strong&gt; unchecked.&lt;/p&gt;&#10;&lt;p&gt;Declare this Input File:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$(TARGET_BUILD_DIR)/$(INFOPLIST_PATH)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Declare this Output File:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$(DERIVED_FILE_DIR)/verified-app-identity.stamp&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Uncheck &lt;strong&gt;Based on dependency analysis&lt;/strong&gt; for this small guard. It should validate every build, including repeated archives. The input declaration still establishes the dependency on plist processing; visual phase order alone is insufficient. &lt;a&#10; href="https://developer.apple.com/documentation/xcode/running-custom-scripts-during-a-build"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Apple&amp;rsquo;s Run Script documentation&lt;/a&gt;&#10; explains how declared inputs and outputs determine task ordering.&lt;/p&gt;&#10;&lt;p&gt;Paste this script, adapting the explicit configuration allowlist:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -eu&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;: &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SCRIPT_OUTPUT_FILE_0&lt;/span&gt;&lt;span class="p"&gt;:?Declare the stamp Output File&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;stamp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_OUTPUT_FILE_0&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rm -f &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$stamp&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;: &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TARGET_BUILD_DIR&lt;/span&gt;&lt;span class="p"&gt;:?&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;INFOPLIST_PATH&lt;/span&gt;&lt;span class="p"&gt;:?&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CONFIGURATION&lt;/span&gt;&lt;span class="p"&gt;:?&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;plist&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$TARGET_BUILD_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$INFOPLIST_PATH&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;fail&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;error: %s\n&amp;#39;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$*&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$CONFIGURATION&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; in&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Release&lt;span class="o"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;expected_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;com.example.notebook&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;expected_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Notebook&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Debug&lt;span class="o"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;expected_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;com.example.notebook.beta&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;expected_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;NotebookBeta&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; *&lt;span class="o"&gt;)&lt;/span&gt; fail &lt;span class="s2"&gt;&amp;#34;No identity policy for configuration &lt;/span&gt;&lt;span class="nv"&gt;$CONFIGURATION&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;esac&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;[&lt;/span&gt; -f &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$plist&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; fail &lt;span class="s2"&gt;&amp;#34;Missing processed Info.plist: &lt;/span&gt;&lt;span class="nv"&gt;$plist&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;/usr/libexec/PlistBuddy -c &lt;span class="s1"&gt;&amp;#39;Print :CFBundleIdentifier&amp;#39;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$plist&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; fail &lt;span class="s1"&gt;&amp;#39;Cannot read CFBundleIdentifier&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;/usr/libexec/PlistBuddy -c &lt;span class="s1"&gt;&amp;#39;Print :CFBundleDisplayName&amp;#39;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$plist&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; fail &lt;span class="s1"&gt;&amp;#39;Cannot read CFBundleDisplayName&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$expected_id&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$expected_name&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; fail &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$CONFIGURATION&lt;/span&gt;&lt;span class="s2"&gt; requires &lt;/span&gt;&lt;span class="nv"&gt;$expected_id&lt;/span&gt;&lt;span class="s2"&gt; / &lt;/span&gt;&lt;span class="nv"&gt;$expected_name&lt;/span&gt;&lt;span class="s2"&gt;; got &lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt; / &lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;Verified app identity: %s / %s\n&amp;#39;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;: &amp;gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$stamp&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Do not substitute the source &lt;code&gt;INFOPLIST_FILE&lt;/code&gt;: generated keys and build-setting expansion can change the packaged values. Keep the expected identity independent of the settings under test.&lt;/p&gt;&#10;&lt;p&gt;The declared paths also describe the script&amp;rsquo;s file access for user-script sandboxing. Keep sandboxing enabled where possible and test these declarations with your Xcode version. The original target had it disabled. The stamp lives outside the signed app and is created only after validation. Removing it first, failing on missing values, and running unconditionally prevent an old stamp from becoming a false success after a failed check.&lt;/p&gt;&#10;&#10;&lt;h2 id="prove-that-wrong-builds-fail"&gt;Prove that wrong builds fail &lt;/h2&gt;&#10;&lt;p&gt;The historical implementation passed normal builds across all four original configurations. Four deliberate mutations failed: the production ID, production name, beta ID, and beta name, changed separately. Each failure reported the mismatched identity; the project file was restored afterward. These were build checks, not evidence of a fresh Archive after the fix.&lt;/p&gt;&#10;&lt;p&gt;For your project, repeat those negative controls, then restore the settings and archive twice without cleaning. Confirm the guard runs both times and inspect the resulting archive&amp;rsquo;s app plist. Also test a missing key and an unrecognized configuration.&lt;/p&gt;&#10;&lt;p&gt;This checks the app&amp;rsquo;s base plist only. Localized &lt;code&gt;InfoPlist.strings&lt;/code&gt; can affect the displayed name, and extensions need separate policies. Check the Archive action&amp;rsquo;s selected configuration in your scheme, then deliberately break its display name and confirm Archive fails before trusting the guard.&lt;/p&gt;&#10;</description></item><item><title>An offline cache TTL needs a clock-rollback policy</title><link>https://eghuro.cz/posts/offline-cache-clock-rollback/</link><pubDate>Tue, 27 Oct 2026 15:44:00 +0100</pubDate><guid>https://eghuro.cz/posts/offline-cache-clock-rollback/</guid><description>&lt;p&gt;AAn offline cache can pass its age check after the device clock moves backward. A timestamp later than &amp;ldquo;now&amp;rdquo; produces a negative age, and a check that only asks whether age is below the TTL accepts it. Reject future timestamps and remember the latest wall-clock time observed, while recognizing that neither check provides trusted elapsed time.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-negative-age-passed-the-upper-bound-check"&gt;A negative age passed the upper-bound check &lt;/h2&gt;&#10;&lt;p&gt;The feature reused a biometric-protected Keychain cache shared with a companion extension. Its initial eligibility rule was effectively:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;now - lastWritten &amp;lt;= maxAge&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That has two problems. Moving the clock before the write makes the age negative. Moving it backward after a previous access can also make an expired cache look young again without crossing the write timestamp.&lt;/p&gt;&#10;&lt;p&gt;The cache contained independently written items. Using the newest item&amp;rsquo;s date would make an older item appear refreshed, so eligibility used the oldest modification date. A separate check rejected any item dated in the future. Checking only the oldest date would miss a newer, future-dated item.&lt;/p&gt;&#10;&#10;&lt;h2 id="retain-the-latest-observed-time"&gt;Retain the latest observed time &lt;/h2&gt;&#10;&lt;p&gt;The repair added a clock mark in a separate Keychain item. Each eligibility read retained &lt;code&gt;max(previousMark, now)&lt;/code&gt;. If &lt;code&gt;now&lt;/code&gt; preceded the previous mark, access was refused. Writing the rolled-back time into the mark would let the next read pass, so repeated reads were part of the regression test.&lt;/p&gt;&#10;&lt;p&gt;This Python model isolates the arithmetic from Keychain storage. Inputs are timestamps in the same unit and a nonnegative maximum age. &lt;code&gt;None&lt;/code&gt; represents initial mark creation; it does not distinguish first use from a failed storage read.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cache_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;written_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mark&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_age&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;written_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;unavailable&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mark&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;previous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;mark&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;mark&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;updated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;fresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;written_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;written_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;max_age&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;fresh&amp;#34;&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;fresh&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;expired&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;updated&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Persist the returned mark reliably. Define separate behavior for a missing mark, failed reads, and failed writes. Serialize the read/advance/write operation across its writers: two callers storing independently calculated maxima can otherwise overwrite a newer mark with an older one. This function does not implement those storage guarantees.&lt;/p&gt;&#10;&lt;p&gt;A successful online sign-in reset the mark to the current time, allowing recovery after a fast clock was legitimately corrected. That reset belonged to the authenticated online path, not to an offline retry.&lt;/p&gt;&#10;&#10;&lt;h2 id="refusing-access-did-not-mean-deleting-the-cache"&gt;Refusing access did not mean deleting the cache &lt;/h2&gt;&#10;&lt;p&gt;The main app&amp;rsquo;s expiry policy could not delete data the companion extension still needed. An expired result blocked that entry point and requested an online sign-in; it left the shared cache intact.&lt;/p&gt;&#10;&lt;p&gt;Offline use also had to stay separate from an ordinary signed-in state. Otherwise, the normal cache writer could rewrite cached data, advance its modification dates, and renew its apparent freshness without server contact. A timestamp is useful evidence of synchronization only while that write invariant holds.&lt;/p&gt;&#10;&lt;p&gt;Eligibility was checked again when opening the cache. A login screen left visible across the deadline could not reuse the earlier &amp;ldquo;fresh&amp;rdquo; result.&lt;/p&gt;&#10;&#10;&lt;h2 id="test-the-rollback-twice"&gt;Test the rollback twice &lt;/h2&gt;&#10;&lt;p&gt;The original implementation&amp;rsquo;s red run reported four failed clock-mark assertions, including rollback refusal, repeated refusal, mark advancement, and online-sign-in reset. After the repair, the recorded core-target run passed 184 tests in 31 suites. Those are historical Xcode results, not a new Keychain integration run.&lt;/p&gt;&#10;&lt;p&gt;A separate run of the function above passed 15 synthetic-timestamp tests, covering expiry boundaries, future-dated items, repeated rollback refusal, and rechecking at open without mutating the cache. It did not exercise Keychain persistence. For example, two reads with these synthetic inputs must preserve the higher mark:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cache_status&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;190&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mark&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;expired&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;cache_status&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;190&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mark&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;expired&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="a-high-water-mark-cannot-establish-time-that-was-never-observed"&gt;A high-water mark cannot establish time that was never observed &lt;/h2&gt;&#10;&lt;p&gt;A device can spend real time offline while its wall clock never exceeds the saved mark. These checks cannot prove how much time passed, guarantee remote revocation, or withstand a compromised device that can alter the protected state. Biometric access control does not authenticate the clock.&lt;/p&gt;&#10;&lt;p&gt;Test your cache by expiring it, moving the injected clock backward, and reading eligibility twice. Both reads should refuse access, and the second must not inherit a lowered mark.&lt;/p&gt;&#10;</description></item><item><title>iOS jailbreak protection in 2026: detection is a risk signal, not a security boundary</title><link>https://eghuro.cz/posts/ios-jailbreak-protection-2026/</link><pubDate>Mon, 26 Oct 2026 23:20:00 +0100</pubDate><guid>https://eghuro.cz/posts/ios-jailbreak-protection-2026/</guid><description>&lt;p&gt;An iOS app had jailbreak detection, passed an external pentest, and still spent more than a year accepting every jailbroken device because an asynchronous result was discarded. After that bug was fixed, the checks were still a 2025 list of Cydia-era paths that missed current rootless and hidden jailbreaks.&lt;/p&gt;&#10;&lt;p&gt;If the requirement says “the app must not run on a jailbroken phone,” the useful answer in 2026 is not a longer path list. Treat jailbreak detection as a bypassable risk signal, decide what sensitive operation the signal protects, enforce that decision at the server where possible, and test the whole policy against current jailbreak and bypass tools.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-short-version"&gt;The short version &lt;/h2&gt;&#10;&lt;p&gt;A defensible implementation today has five parts:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Write a threat model that explains why a compromised device materially changes this app&amp;rsquo;s risk.&lt;/li&gt;&#10;&lt;li&gt;Combine independent local signals and run them near sensitive operations, not only at launch.&lt;/li&gt;&#10;&lt;li&gt;Apply a proportional policy: observe, step up, restrict sensitive data, or refuse service according to confidence and impact.&lt;/li&gt;&#10;&lt;li&gt;Put the final authorization decision on a server and use App Attest to protect app-to-server requests. Do not call App Attest to prove that a device is not jailbroken.&lt;/li&gt;&#10;&lt;li&gt;Test the release binary statically, dynamically, and by trying to bypass it on representative clean and jailbroken devices.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;This is also the difference between “the code contains a jailbreak check” and an engineering conclusion that can survive review.&lt;/p&gt;&#10;&#10;&lt;h2 id="how-a-working-check-silently-stopped-working"&gt;How a working check silently stopped working &lt;/h2&gt;&#10;&lt;p&gt;The first implementation was conventional:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;sysctl&lt;/code&gt; checked &lt;code&gt;P_TRACED&lt;/code&gt; and terminated a release build under a debugger;&lt;/li&gt;&#10;&lt;li&gt;known paths such as &lt;code&gt;/Applications/Cydia.app&lt;/code&gt;, &lt;code&gt;/bin/bash&lt;/code&gt;, &lt;code&gt;/usr/sbin/sshd&lt;/code&gt;, and &lt;code&gt;/etc/apt&lt;/code&gt; were checked;&lt;/li&gt;&#10;&lt;li&gt;the app tried to write &lt;code&gt;/private/jailbreak.txt&lt;/code&gt; outside its sandbox;&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;cydia://&lt;/code&gt; was passed to &lt;code&gt;canOpenURL&lt;/code&gt;;&lt;/li&gt;&#10;&lt;li&gt;loaded Mach-O images were searched for &lt;code&gt;MobileSubstrate&lt;/code&gt;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;It ran only in non-Debug builds on physical devices. That was sensible, and the pentest team verified it.&lt;/p&gt;&#10;&lt;p&gt;One probe had never worked in the affected app targets: &lt;code&gt;canOpenURL&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt; for schemes that are not declared under &lt;code&gt;LSApplicationQueriesSchemes&lt;/code&gt;, and &lt;code&gt;cydia&lt;/code&gt; was not declared. An app extension could not use &lt;code&gt;UIApplication.shared&lt;/code&gt; at all. Current IOSSecuritySuite also &lt;a&#10; href="https://github.com/securing/IOSSecuritySuite/blob/master/IOSSecuritySuite/JailbreakChecker.swift"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;removed the &lt;code&gt;cydia://&lt;/code&gt; check&lt;/a&gt;&#10; because an App Store app registered the scheme and caused false positives. URL-scheme probes are weak evidence even when configured correctly.&lt;/p&gt;&#10;&lt;p&gt;Then the check moved behind a function that started a &lt;code&gt;Task&lt;/code&gt;, returned from the function immediately, and ignored the task&amp;rsquo;s &lt;code&gt;Bool&lt;/code&gt;. The user interface always received &lt;code&gt;true&lt;/code&gt;. The debugger check still appeared to work because &lt;code&gt;fatalError&lt;/code&gt; happened inside the task, masking the control-flow bug. Later refactors moved the broken call without changing its behavior.&lt;/p&gt;&#10;&lt;p&gt;The eventual fix was not another jailbreak signature. It was to make validation genuinely asynchronous, await it, and test the production call site as well as the checker.&lt;/p&gt;&#10;&lt;p&gt;That history produced three reusable lessons:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Test enforcement, not just detection. A perfect signal whose result is ignored protects nothing.&lt;/li&gt;&#10;&lt;li&gt;Test every app entry point and extension. The main app, credential provider, widget, and background path do not necessarily share a process or API surface.&lt;/li&gt;&#10;&lt;li&gt;Test the release configuration on hardware. A Debug simulator test proves code structure, not production behavior when the checks are compiled out there.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="when-owasp-says-to-implement-jailbreak-detection"&gt;When OWASP says to implement jailbreak detection &lt;/h2&gt;&#10;&lt;p&gt;&lt;a&#10; href="https://mas.owasp.org/news/2026/07/04/mastg-v200-release"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP MASTG 2.0, released in July 2026&lt;/a&gt;&#10;, makes the trade-off unusually explicit. &lt;a&#10; href="https://mas.owasp.org/MASTG/best-practices/MASTG-BEST-0030"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;MASTG-BEST-0030&lt;/a&gt;&#10; is currently tagged Android and says “root detection,” but its risk principle applies directly to the equivalent iOS control: it is a bypassable, cost-raising environment signal. It should be used only when compromised-device risk materially affects the app. The selection and response should follow the threat model and risk tolerance.&lt;/p&gt;&#10;&lt;p&gt;That points toward jailbreak protection when a compromised platform makes a concrete abuse case easier, for example:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;extracting high-value secrets or regulated data from the app process or local cache;&lt;/li&gt;&#10;&lt;li&gt;instrumenting transactions, credential filling, cryptographic operations, or fraud controls;&lt;/li&gt;&#10;&lt;li&gt;changing a game, licensing, payment, or content-access decision made locally;&lt;/li&gt;&#10;&lt;li&gt;automating a protected API through a modified client;&lt;/li&gt;&#10;&lt;li&gt;defeating controls that depend on the sandbox, Keychain access policy, or trusted user interaction.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;It does not make jailbreak detection a universal mobile-app baseline. A low-risk public-content app may gain little while inheriting false positives, support work, accessibility problems, and an exclusion policy it cannot reliably enforce.&lt;/p&gt;&#10;&lt;p&gt;&lt;a&#10; href="https://mas.owasp.org/MASVS/controls/MASVS-RESILIENCE-1"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;MASVS-RESILIENCE-1&lt;/a&gt;&#10; states the security concern: many other controls assume that the platform&amp;rsquo;s sandboxing, secure storage, and biometrics can be trusted. But the wider &lt;a&#10; href="https://mas.owasp.org/MASVS/11-MASVS-RESILIENCE"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;MASVS resilience guidance&lt;/a&gt;&#10; treats anti-tampering and runtime self-protection as defense in depth, never a replacement for the baseline security architecture.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-current-techniques-are-rootless-and-designed-to-hide"&gt;The current techniques are rootless and designed to hide &lt;/h2&gt;&#10;&lt;p&gt;The old signature list described rootful Cydia and MobileSubstrate installations. Current jailbreaks changed the filesystem layout and then added explicit concealment.&lt;/p&gt;&#10;&lt;p&gt;&lt;a&#10; href="https://github.com/opa334/Dopamine"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Dopamine&lt;/a&gt;&#10; is a rootless jailbreak. Its bootstrap commonly appears below &lt;code&gt;/var/jb&lt;/code&gt;, with ElleKit providing tweak injection. Dopamine 1.1 &lt;a&#10; href="https://github.com/opa334/Dopamine/releases/tag/1.1"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;introduced &lt;code&gt;JB_ROOT_PATH&lt;/code&gt;&lt;/a&gt;&#10; in the environment of processes with tweaks enabled, while noting that &lt;code&gt;/var/jb&lt;/code&gt; might become optional to impede detection. Palera1n has its own loader and helper artifacts. Frida may appear as &lt;code&gt;frida-server&lt;/code&gt;, Frida Gadget, a loaded image, or merely runtime behavior after its obvious names have been changed.&lt;/p&gt;&#10;&lt;p&gt;At the time of this review, Dopamine&amp;rsquo;s current upstream release was &lt;a&#10; href="https://github.com/opa334/Dopamine/releases/tag/3.0.12"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;3.0.12, published October 20, 2026&lt;/a&gt;&#10;. A signature inventory that stops at Cydia or even Dopamine 2 is already historical.&lt;/p&gt;&#10;&lt;p&gt;Dopamine 2.4&amp;rsquo;s &lt;a&#10; href="https://github.com/opa334/Dopamine/releases/tag/2.4"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;release notes&lt;/a&gt;&#10; are the more important evidence: “Hide Jailbreak” was changed specifically to hide the jailbreak to the point where it should be undetectable. &lt;a&#10; href="https://github.com/roothide/Dopamine-roothide"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;RootHide&amp;rsquo;s Dopamine integration&lt;/a&gt;&#10; randomizes the jailbreak root and selectively keeps jailbreak changes away from blacklisted apps. Its purpose is jailbreak-detection bypass.&lt;/p&gt;&#10;&lt;p&gt;A 2026 local detector should therefore look across different classes of evidence, for example:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;rootful and rootless files, directories, symlinks, package-manager state, and randomized &lt;code&gt;.jbroot-*&lt;/code&gt; roots;&lt;/li&gt;&#10;&lt;li&gt;whether the sandbox permits a collision-safe write to a protected path;&lt;/li&gt;&#10;&lt;li&gt;debugger state and failure of the debugger-status query itself;&lt;/li&gt;&#10;&lt;li&gt;suspicious environment keys such as &lt;code&gt;DYLD_INSERT_LIBRARIES&lt;/code&gt; and &lt;code&gt;JB_ROOT_PATH&lt;/code&gt;;&lt;/li&gt;&#10;&lt;li&gt;loaded images associated with Frida, ElleKit, Substrate, Substitute, libhooker, RootHide, or Dopamine&amp;rsquo;s system hook;&lt;/li&gt;&#10;&lt;li&gt;behavior that should be prohibited by the sandbox, where the API is appropriate for App Store software;&lt;/li&gt;&#10;&lt;li&gt;server-side anomalies and app-integrity assertions.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Use more than one API for important filesystem evidence. A &lt;code&gt;FileManager.fileExists&lt;/code&gt; check, a POSIX &lt;code&gt;lstat&lt;/code&gt;, and directory enumeration do not create certainty, but an attacker now has more hooks to maintain consistently.&lt;/p&gt;&#10;&lt;p&gt;Do not equate more signatures with “state of the art.” Public signature lists are useful coverage for unsophisticated and accidentally exposed jailbreaks. RootHide, renamed Frida components, or a patch to the function that aggregates all checks can make every local answer say “clean.” OWASP&amp;rsquo;s &lt;a&#10; href="https://mas.owasp.org/MASTG/knowledge/ios/MASVS-RESILIENCE/MASTG-KNOW-0084"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;jailbreak detection knowledge article&lt;/a&gt;&#10; says the same thing: these defenses are not effective by themselves, although distributing them through the app increases the cost of bypassing them.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-response-matters-more-than-the-boolean"&gt;The response matters more than the Boolean &lt;/h2&gt;&#10;&lt;p&gt;“Jailbroken: yes or no” is too weak a model for a fallible set of signals. Record an internal risk result with the evidence and confidence, then map it to a product policy.&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Situation&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Reasonable response&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;One weak or ambiguous signal&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Telemetry, retry, or step-up authentication&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Several independent local signals&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Block sensitive operations; avoid disclosing the exact failed probe&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Debugger or injected runtime tool in a high-risk flow&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Refuse the operation and invalidate relevant server state&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Unsupported App Attest on an otherwise supported device&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Treat as a risk signal according to the documented policy&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Enterprise-managed fleet with a strict posture requirement&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Evaluate Managed Device Attestation at the relying service&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;OWASP recommends proportional responses and warns that aggressive lockout can affect legitimate users. For an app that stores documents and credentials, a useful soft block can allow login and folder metadata while refusing document bodies, downloads, offline queues, and credential release.&lt;/p&gt;&#10;&lt;p&gt;That restriction must be in application logic, not just a screen with a Close button. Better still, the server should not return the sensitive payload. Otherwise, a hook can dismiss the screen or call the same API directly.&lt;/p&gt;&#10;&lt;p&gt;A hard process exit is appropriate only if the threat model justifies denial of all functionality and the product accepts false positives. Crashing also gives poor observability and can turn an operational failure into a permanent lockout. A generic refusal with server-side telemetry is usually easier to operate.&lt;/p&gt;&#10;&#10;&lt;h2 id="app-attest-authenticates-app-instances-not-clean-devices"&gt;App Attest authenticates app instances, not clean devices &lt;/h2&gt;&#10;&lt;p&gt;Apple&amp;rsquo;s &lt;a&#10; href="https://developer.apple.com/videos/play/wwdc2026/201"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;WWDC26 App Attest session&lt;/a&gt;&#10; describes the current protocol:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;the server initiates enrollment and issues a challenge;&lt;/li&gt;&#10;&lt;li&gt;the app creates a Secure Enclave-bound key and attests it;&lt;/li&gt;&#10;&lt;li&gt;the server validates and stores the attestation and public key;&lt;/li&gt;&#10;&lt;li&gt;sensitive requests include a fresh server challenge in an assertion over the payload;&lt;/li&gt;&#10;&lt;li&gt;the server validates the signature and requires a strictly increasing counter to reject replays.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;This is valuable. It lets the server distinguish a valid published app instance, detect unexpected signing or launch categories, and bind authorization to a payload that has not been altered in transit.&lt;/p&gt;&#10;&lt;p&gt;It is not a jailbreak oracle. The same 2026 session says that a compromised device can still pass attestations and broker valid attestations for modified clients. Apple provides the fraud metric to help detect that pattern. Use App Attest as another server-verified signal, with retry and onboarding policy for legitimate failures.&lt;/p&gt;&#10;&lt;p&gt;For managed organizational devices, &lt;a&#10; href="https://support.apple.com/guide/security/managed-device-attestation-sec8a37b4cb2/web"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Managed Device Attestation&lt;/a&gt;&#10; provides Secure Enclave-backed evidence about device identity and security posture. That is stronger input for a managed-fleet trust decision, but it is not a general capability that a consumer app can require from every unmanaged phone.&lt;/p&gt;&#10;&#10;&lt;h2 id="test-implementation-behavior-and-bypass-resistance"&gt;Test implementation, behavior, and bypass resistance &lt;/h2&gt;&#10;&lt;p&gt;OWASP MASTG 2 separates two questions that teams often collapse into one.&lt;/p&gt;&#10;&lt;p&gt;&lt;a&#10; href="https://mas.owasp.org/MASTG/tests/ios/MASVS-RESILIENCE/MASTG-TEST-0240"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;MASTG-TEST-0240&lt;/a&gt;&#10; is static: extract the shipped app package and inspect the binaries for jailbreak-detection mechanisms. This catches missing code, build flags that compiled it away, or a check that never reached a target. OWASP warns that automated static analysis is not exhaustive; manual reverse engineering may be necessary.&lt;/p&gt;&#10;&lt;p&gt;&lt;a&#10; href="https://mas.owasp.org/MASTG/tests/ios/MASVS-RESILIENCE/MASTG-TEST-0241"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;MASTG-TEST-0241&lt;/a&gt;&#10; is dynamic: run the app, identify the checks, and try to bypass them. A test fails when detection is absent, but passing a single predefined bypass tool doesn&amp;rsquo;t prove resilience because its scripts may be incomplete or outdated.&lt;/p&gt;&#10;&lt;p&gt;For a real release, I would keep this matrix with the evidence from each run:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Test&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Expected evidence&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Clean supported iPhones and iOS versions&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;No false refusal; all sensitive flows work&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Release build on physical hardware&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Checks execute; Debug/simulator exclusions do not leak into the archive&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Rootful and current rootless jailbreaks&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Each applicable signal fires; policy is enforced&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Dopamine Hide Jailbreak and RootHide blacklist&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Document what remains visible and what becomes undetectable&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Frida server and embedded/renamed Gadget&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Known artifacts are detected; renamed forms challenge bypass resistance; outcomes are recorded&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Hook the aggregate result and individual APIs&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Sensitive server operation still requires valid authorization and assertions&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Main app, extensions, background entry points&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Each entry point validates before releasing its protected capability&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Async and error paths&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Results are awaited; query failures follow the documented fail-open or fail-closed policy&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Pentest bypass configuration&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;It cannot enter a production archive; CI tests that invariant&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Unit-test injected signals, cleanup failures, query failures, and clean-device results. Then add integration tests around the call site: the protected operation must not run after validation fails. Finally, let a tester attack the binary rather than merely demonstrating a known file on a jailbroken test phone.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-reviewable-2026-lege-artis-baseline"&gt;A reviewable 2026 “lege artis” baseline &lt;/h2&gt;&#10;&lt;p&gt;“State of the art” is not established by citing one library or by counting checks. For an engineering review, the conclusion should be reproducible, dated, scoped, and explicit about uncertainty. This is the practical baseline I would use when assessing whether an iOS app&amp;rsquo;s jailbreak protection was implemented with professional care in October 2026:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;strong&gt;Define the question and date.&lt;/strong&gt; State which app version, release configuration, iOS range, device classes, sensitive operations, and attacker capabilities are in scope. Practice changes as jailbreaks and iOS change.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Identify the risk before prescribing the control.&lt;/strong&gt; Connect compromised-platform capabilities to actual assets and abuse cases. Explain why jailbreak detection materially affects this app.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Select authoritative and current sources.&lt;/strong&gt; Record versions and access dates for OWASP MASVS/MASTG, Apple platform documentation, relevant jailbreak release notes, and the implementation under review. Treat public detection libraries as examples, not normative standards.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Preserve and identify the evidence.&lt;/strong&gt; Record the source commit, archive hash, build settings, entitlements, tool versions, device/iOS versions, jailbreak version, and test logs. Work from a reproducible copy.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Separate facts from interpretation.&lt;/strong&gt; “The production binary contains &lt;code&gt;/var/jb&lt;/code&gt; and &lt;code&gt;JB_ROOT_PATH&lt;/code&gt; checks” is a fact. “Those checks make bypass harder but cannot establish a clean device” is the interpretation supported by OWASP and current bypass projects.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Analyze the whole control path.&lt;/strong&gt; Review signal collection, aggregation, async propagation, every entry point, the product response, server authorization, telemetry, and release-lane exceptions. Include negative findings and dead code.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Test both presence and effectiveness.&lt;/strong&gt; Perform static inspection, clean-device compatibility tests, dynamic execution on relevant jailbreaks, and bypass attempts. A unit test or a pentest on one jailbreak version is not enough.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Consider alternatives and false positives.&lt;/strong&gt; Explain why a detected artifact could be benign, why a missing artifact does not establish cleanliness, and why the chosen response is proportional.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;State limitations.&lt;/strong&gt; Local checks can be hooked or patched; hidden jailbreaks may expose no artifact; App Attest can succeed on a compromised device; the tested device set is finite.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Make a bounded conclusion.&lt;/strong&gt; Say whether the examined implementation follows the documented risk policy and raises attacker cost for the tested scenarios. Do not conclude that it guarantees a non-jailbroken device.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;This structure mirrors a sound software expert analysis: sources and methods are identified, observations remain distinguishable from interpretation, contrary evidence is not hidden, limitations reduce the stated certainty, and the conclusion answers only the technical question examined. It is guidance for an engineering assessment, not a legal conclusion about liability, contractual compliance, or negligence.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;The old implementation failed twice: first because application control flow discarded a valid result, then because its threat model had aged past its Cydia-era signatures. The durable fix is a process—current layered signals, proportional server-enforced policy, and repeated adversarial testing—not a permanent &lt;code&gt;isJailbroken&lt;/code&gt; function.&lt;/p&gt;&#10;&lt;p&gt;Take one sensitive operation in your app and trace it today from device signal to server authorization. If any step can ignore, hide, or locally override the result, that is the next protection to fix.&lt;/p&gt;&#10;&#10;&lt;h2 id="references"&gt;References &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://mas.owasp.org/MASVS/11-MASVS-RESILIENCE"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP MASVS: Resilience against reverse engineering and tampering&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://mas.owasp.org/news/2026/07/04/mastg-v200-release"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP MASTG 2.0.0 release announcement&lt;/a&gt;&#10;, July 4, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://mas.owasp.org/MASVS/controls/MASVS-RESILIENCE-1"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP MASVS-RESILIENCE-1: Platform integrity&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://mas.owasp.org/MASTG/best-practices/MASTG-BEST-0030"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP MASTG-BEST-0030: Implementing Root Detection&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://mas.owasp.org/MASTG/knowledge/ios/MASVS-RESILIENCE/MASTG-KNOW-0084"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP MASTG-KNOW-0084: Jailbreak Detection&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://mas.owasp.org/MASTG/tests/ios/MASVS-RESILIENCE/MASTG-TEST-0240"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP MASTG-TEST-0240: Jailbreak Detection in Code&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://mas.owasp.org/MASTG/tests/ios/MASVS-RESILIENCE/MASTG-TEST-0241"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP MASTG-TEST-0241: Runtime Use of Jailbreak Detection Techniques&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://developer.apple.com/videos/play/wwdc2026/201"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Apple: Secure your apps with App Attest, WWDC26&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://support.apple.com/guide/security/managed-device-attestation-sec8a37b4cb2/web"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Apple Platform Security: Managed Device Attestation&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://github.com/opa334/Dopamine/releases/tag/2.4"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Dopamine 2.4 release notes: Hide Jailbreak&lt;/a&gt;&#10;, February 16, 2025.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://github.com/opa334/Dopamine/releases/tag/1.1"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Dopamine 1.1 release notes: &lt;code&gt;JB_ROOT_PATH&lt;/code&gt;&lt;/a&gt;&#10;, June 16, 2023.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://github.com/opa334/Dopamine/releases"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Dopamine releases&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://github.com/opa334/Dopamine/releases/tag/3.0.12"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Dopamine 3.0.12 release&lt;/a&gt;&#10;, October 20, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://github.com/roothide/Dopamine-roothide"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;RootHide integration for Dopamine 3&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://github.com/securing/IOSSecuritySuite/blob/master/IOSSecuritySuite/JailbreakChecker.swift"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;IOSSecuritySuite jailbreak checker&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;</description></item><item><title>A five-second Swift Testing wait timed out after its loop had run five times</title><link>https://eghuro.cz/posts/swift-testing-wait-until-main-actor-starvation/</link><pubDate>Thu, 22 Oct 2026 16:12:00 +0200</pubDate><guid>https://eghuro.cz/posts/swift-testing-wait-until-main-actor-starvation/</guid><description>&lt;p&gt;Five concurrency tests passed on their own in 0.086 seconds. In the full target, they failed together at about 6.8 seconds:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✘ Test fetch_threeSources_asksForEverySourceAtOnce() recorded an issue at FetcherConcurrencyTests.swift:21:30:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Expectation failed: ContinuousClock.now &amp;lt; deadline&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✘ Test fetch_threeSources_asksForEverySourceAtOnce() failed after 6.816 seconds with 1 issue.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It looked like a deadlock in the code under test. It was a test helper measuring the wrong thing.&lt;/p&gt;&#10;&#10;&lt;h2 id="setup"&gt;Setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Swift Testing, an Xcode test target that mixes &lt;code&gt;@MainActor&lt;/code&gt; unit suites with snapshot suites.&lt;/li&gt;&#10;&lt;li&gt;Two test plans run the same target. One sets &lt;code&gt;&amp;quot;parallelizable&amp;quot;: false&lt;/code&gt; for it, and the other doesn&amp;rsquo;t. Only the parallel plan failed.&lt;/li&gt;&#10;&lt;li&gt;A small polling helper that the concurrent-fetch tests use to wait for a gated fake service to see requests:&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;until&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;sourceLocation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SourceLocation&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;#&lt;/span&gt;&lt;span class="n"&gt;_sourceLocation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kc"&gt;_&lt;/span&gt; &lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Bool&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;async&lt;/span&gt; &lt;span class="kr"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;deadline&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ContinuousClock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;advanced&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;#&lt;/span&gt;&lt;span class="n"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ContinuousClock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;&amp;#34;Timed out waiting for the condition to hold&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;sourceLocation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sourceLocation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;yield&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The helper runs on the main actor. Each &lt;code&gt;Task.yield()&lt;/code&gt; lets the queued work, in this case the fetcher opening its requests, run before the condition is checked again.&lt;/p&gt;&#10;&#10;&lt;h2 id="reproduce-before-guessing"&gt;Reproduce before guessing &lt;/h2&gt;&#10;&lt;p&gt;Running the target alone under the parallel plan with &lt;code&gt;-test-iterations&lt;/code&gt; and &lt;code&gt;-run-tests-until-failure&lt;/code&gt; failed 3 out of 3 times, always in the same tests and always at the wall-clock deadline. So it wasn&amp;rsquo;t a rare flake. It failed every time under that plan.&lt;/p&gt;&#10;&lt;p&gt;To check whether the waits were stuck or just slow, I temporarily replaced the deadline with a counter and printed what each wait used:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;start&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ContinuousClock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;turns&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;turns&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;yield&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="bp"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;WAITPROBE turns=&lt;/span&gt;&lt;span class="si"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;turns&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s"&gt; elapsed=&lt;/span&gt;&lt;span class="si"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;ContinuousClock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s"&gt; at &lt;/span&gt;&lt;span class="si"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;sourceLocation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;5 5.766 FetcherConcurrencyTests.swift:54&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;5 5.712 FetcherConcurrencyTests.swift:21&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;4 6.411 FetcherConcurrencyTests.swift:37&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;3 5.704 SyncLoggingTests.swift:25&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;3 5.338 InFlightTests.swift:20&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Every wait finished, but the slowest needed only &lt;strong&gt;3 to 5 turns&lt;/strong&gt; and took &lt;strong&gt;more than 5 seconds&lt;/strong&gt; of wall time. The condition was met on almost the first chance the fetcher got to run. It just didn&amp;rsquo;t get that chance for seconds.&lt;/p&gt;&#10;&lt;p&gt;The reason showed up when I sorted tests by duration. In the parallel plan, snapshot suites share the process and the main actor, and each render holds the actor synchronously, some of them for over 7 seconds. While a render ran, the waiting test&amp;rsquo;s next turn just sat in the queue, and the clock kept running.&lt;/p&gt;&#10;&#10;&lt;h2 id="dead-end-a-longer-timeout"&gt;Dead end: a longer timeout &lt;/h2&gt;&#10;&lt;p&gt;I tried a 60-second deadline as an experiment. Both runs passed, with the concurrency tests taking about 7.9 seconds each.&lt;/p&gt;&#10;&lt;p&gt;That shows the cause, but it isn&amp;rsquo;t a fix. 60 is no more principled than 5. A heavier snapshot suite brings the failure back, and a test that really hangs now takes a minute to report it. I reverted it.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix-spend-both-budgets-before-failing"&gt;Fix: spend both budgets before failing &lt;/h2&gt;&#10;&lt;p&gt;The wait should fail only once it has had enough chances to make progress and enough time has passed:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;until&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;sourceLocation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SourceLocation&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;#&lt;/span&gt;&lt;span class="n"&gt;_sourceLocation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;timeBudget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kc"&gt;_&lt;/span&gt; &lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Bool&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;async&lt;/span&gt; &lt;span class="kr"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;deadline&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ContinuousClock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;advanced&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timeBudget&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;turns&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;hasBudgetLeft&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;turns&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;Constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;turnBudget&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;ContinuousClock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;deadline&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;#&lt;/span&gt;&lt;span class="n"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hasBudgetLeft&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;&amp;#34;Timed out waiting for the condition to hold&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;sourceLocation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sourceLocation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;turns&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;yield&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;Constants&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;/// A wait under a fully loaded run of this target took at most 5 turns.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;turnBudget&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;On an idle main actor, 100 turns go by in milliseconds, so a condition that never holds still fails just after the time budget. On a starved actor, the wait keeps going until it has had real chances to run.&lt;/p&gt;&#10;&#10;&lt;h2 id="pin-it-with-a-controlled-starvation-test"&gt;Pin it with a controlled starvation test &lt;/h2&gt;&#10;&lt;p&gt;The full target is a slow, noisy reproduction, so I wrote a test that intentionally causes starvation. It makes &lt;code&gt;timeBudget&lt;/code&gt; a parameter, holds the main actor for longer than that budget, and makes the condition true only afterward:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="n"&gt;MainActor&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="n"&gt;Suite&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;WaitTests&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="n"&gt;Test&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;until_whenTheMainActorIsHeldPastTheTimeBudget_waitsForTheCondition&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;async&lt;/span&gt; &lt;span class="kr"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;holds&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;LockIsolated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="n"&gt;MainActor&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;forTimeInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// stands in for a snapshot render&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="n"&gt;MainActor&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;holds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Wait&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;until&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeBudget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;milliseconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;holds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="n"&gt;Test&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;until_whenTheConditionNeverHolds_fails&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;withKnownIssue&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Wait&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;until&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeBudget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;zero&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Before the fix, it failed after 0.335 seconds with the same &lt;code&gt;ContinuousClock.now &amp;lt; deadline&lt;/code&gt; error. After the fix, it passed in 0.303 seconds. The second test makes sure the helper can still fail.&lt;/p&gt;&#10;&#10;&lt;h2 id="verify-both-directions"&gt;Verify both directions &lt;/h2&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Check&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Result&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Parallel plan, before the fix&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;0 of 3 passed&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Parallel plan, after the fix&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;5 of 5 passed&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Serial plan, whole target&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;690 tests passed&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Mutant: fetcher limited to one request at a time&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;all 5 concurrency tests failed, each at 5.0 s&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;The last row matters most. A longer timeout would also have made the parallel runs pass. What it wouldn&amp;rsquo;t keep is fast failure for a real bug. I changed the fetcher&amp;rsquo;s concurrency limit from 4 to 1, so the &amp;ldquo;all three requests are open at once&amp;rdquo; condition could never be true. All five tests caught it, each at the 5-second time budget, and I reverted the mutation.&lt;/p&gt;&#10;&lt;p&gt;One caveat: that mutant ran under the serial plan. Under heavy parallel load, a real hang fails after 100 turns, which takes longer than 5 seconds. That&amp;rsquo;s the trade-off I chose, and it applies to this helper and this workload. It isn&amp;rsquo;t a general fairness guarantee for &lt;code&gt;Task.yield()&lt;/code&gt;.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-second-guess-hiding-in-the-same-tests"&gt;A second guess hiding in the same tests &lt;/h2&gt;&#10;&lt;p&gt;Three tests waited for the &lt;em&gt;first&lt;/em&gt; request and then gave the scheduler &amp;ldquo;five settling turns&amp;rdquo; before asserting that all three were open. That turn count was another guess, and it could fail under load the same way. The fix was to wait for the exact condition:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// before&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Wait&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;until&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pendingCount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Wait&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;untilSettled&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;#&lt;/span&gt;&lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pendingCount&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// after&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Wait&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;until&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pendingCount&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;A wall-clock timeout in a polling helper measures how busy the process is, not whether your code is stuck. When a wait times out only in the big parallel run, count its turns before you suspect a deadlock. If it got only a handful of turns, the actor was starved. Make the helper fail only after both a time budget and a turn budget run out. Then check the fix with a mutation that should hang and make sure it still fails quickly.&lt;/p&gt;&#10;&lt;p&gt;Try it on your own polling helper: add a turn counter and run the full target in parallel once. If the worst wait used single-digit turns but took seconds, the timeout is measuring main-actor contention.&lt;/p&gt;&#10;</description></item><item><title>Snapshot precision 0.99 let a wrong text line pass all seven screen tests</title><link>https://eghuro.cz/posts/snapshot-precision-099-hides-the-field-under-test/</link><pubDate>Tue, 13 Oct 2026 17:04:00 +0200</pubDate><guid>https://eghuro.cz/posts/snapshot-precision-099-hides-the-field-under-test/</guid><description>&lt;p&gt;A code review claimed that a list screen&amp;rsquo;s snapshot tests couldn&amp;rsquo;t fail on the very line they were written to pin: a small gray &amp;ldquo;Location&amp;rdquo; line under each search result. To check, I broke that line on purpose. The rows got the container&amp;rsquo;s short name instead of its full name, so every location line rendered the wrong text. Then I ran the suite:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✔ Test listScreenDeletedItemsShowTheirOrigin() passed after 0.296 seconds.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✔ Test listScreenSearchResultsShowTheirLocation() passed after 0.708 seconds.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✔ Test run with 7 tests in 1 suite passed after 0.793 seconds.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;All seven passed against the broken code. If your snapshot tests record a whole screen at &lt;code&gt;.device&lt;/code&gt; layout with a &lt;code&gt;precision&lt;/code&gt; below 1.0, they may be just as blind.&lt;/p&gt;&#10;&#10;&lt;h2 id="setup"&gt;Setup &lt;/h2&gt;&#10;&lt;p&gt;The project used &lt;a&#10; href="https://github.com/pointfreeco/swift-snapshot-testing"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;swift-snapshot-testing&lt;/a&gt;&#10; 1.19.2, with Swift Testing on the iOS simulator. A shared defaults file sets both tolerances:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;perceptualPrecision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Float&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.90&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Float&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.99&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The two gates do different jobs:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;perceptualPrecision&lt;/code&gt; decides whether a single pixel counts as different. 0.90 absorbs anti-aliasing drift between developer Macs.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;precision&lt;/code&gt; decides what fraction of pixels must match. 0.99 lets 1% of the image differ by any amount.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The 0.99 was there to unblock CI: one render drifted by about 3,600 pixels between machines. The comment next to it even warned that it was &amp;ldquo;wide enough to hide a small real regression&amp;rdquo;. Every screen test used it anyway.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-arithmetic"&gt;The arithmetic &lt;/h2&gt;&#10;&lt;p&gt;An iPhone 17 Pro device frame is 1179×2556 pixels.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;1179 × 2556 = 3,013,524 px&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;1% of that = 30,135 px may differ freely&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;wrong location = ~900 px actually differed&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A 1% budget sounds strict. On a full screen it&amp;rsquo;s about a 58-point square of arbitrary change, more than all the location lines on the screen combined. The test was recorded correctly and passed honestly. It just had no way to fail on the thing in its name.&lt;/p&gt;&#10;&#10;&lt;h2 id="measuring-the-mutation"&gt;Measuring the mutation &lt;/h2&gt;&#10;&lt;p&gt;To size the gate, record the mutated renders, diff them against the committed references, then restore the references. A few lines of Pillow give a rough count (a pixel counts if any channel moves by more than 25):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;sys&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;PIL&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;RGB&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;RGB&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;c2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c2&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;pb&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;.2%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; of &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;x&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;TEST_RUNNER_SNAPSHOT_TESTING_RECORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;all xcodebuild &lt;span class="nb"&gt;test&lt;/span&gt; ... -only-testing:MyTests/ListScreenSnapshotTests&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git show HEAD:path/to/reference.png &amp;gt; /tmp/head.png&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 pxdiff.py /tmp/head.png path/to/reference.png&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git checkout -- path/to/__Snapshots__ &lt;span class="c1"&gt;# put the real references back&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...DarkAppearance...: 0.03% of 1179x2556&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...SearchResultsAtTheDynamicTypeCap...: 0.14% of 1179x2556&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...SearchResultsShowTheirLocation...: 0.03% of 1179x2556&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...DeletedItemsShowTheirOrigin...: 0.03% of 1179x2556&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The smallest real defect was 0.03%, about 900 pixels, so the budget had to sit well below that. 0.9999 allows 301.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-fix"&gt;The fix &lt;/h2&gt;&#10;&lt;p&gt;Give the screen suite its own precision and write the numbers down where the next person will see them:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;perceptualPrecision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SnapshotDefaults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;perceptualPrecision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;Constants&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;/// A device frame is 1179×2556 px, so the default 1% budget is 30,135 px: wider than every&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;/// Location line on the screen together. The wrong name moves about 900 px; this leaves a&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;/// 301 px budget, while `perceptualPrecision` still absorbs anti-aliasing drift per pixel.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Float&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.9999&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then rerun with the mutation still in place. It has to go red:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✘ Test listScreenDeletedItemsShowTheirOrigin() failed after 0.594 seconds with 1 issue.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✔ Test listScreenSortMenuOpen() passed after 0.751 seconds.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✔ Test listScreenBrowsingTheRoot() passed after 0.807 seconds.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✘ Test listScreenSearchResultsAtTheDynamicTypeCap() failed after 1.220 seconds with 1 issue.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✘ Test listScreenSearchResultsShowTheirLocation() failed after 1.618 seconds with 1 issue.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✔ Test listScreenSearchWithNoResults() passed after 1.659 seconds.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✘ Test listScreenDarkAppearance() failed after 2.036 seconds with 1 issue.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The four cases that draw location or origin lines failed. The three cases without them passed, which is also correct. After reverting the mutation, all seven passed again at 0.9999.&lt;/p&gt;&#10;&lt;p&gt;A 301-pixel budget is tight, and I verified it on one machine. If cross-machine drift trips it, look at the drift before you loosen the number.&lt;/p&gt;&#10;&#10;&lt;h2 id="shrink-the-frame-when-you-can"&gt;Shrink the frame when you can &lt;/h2&gt;&#10;&lt;p&gt;For a single component, a tight frame beats a tight precision. The same project measured a toolbar button&amp;rsquo;s enabled-to-disabled color change at 1,808 pixels, or 0.06% of a device frame. Rendered at 140×60 with &lt;code&gt;layout: .fixed(width:height:)&lt;/code&gt;, those 1,808 pixels became 2.4% of the image, well past a 1% budget. Keep &lt;code&gt;.device&lt;/code&gt; for tests that are really about the whole screen, and size their precision from the smallest change they claim to catch.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;precision&lt;/code&gt; is a fraction of the image, so a fixed value forgives more pixels as the frame grows. Before you trust a green snapshot, break the thing it&amp;rsquo;s named after, count the changed pixels, and compare that with &lt;code&gt;(1 - precision) × width × height&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Pick one snapshot test in your suite, break its subject on purpose, and see whether it goes red.&lt;/p&gt;&#10;</description></item><item><title>xcodebuild says TEST SUCCEEDED, the xcresult says "Issues recorded without an associated test or suite"</title><link>https://eghuro.cz/posts/xcodebuild-succeeds-xcresult-unassociated-failure/</link><pubDate>Mon, 28 Sep 2026 12:16:00 +0200</pubDate><guid>https://eghuro.cz/posts/xcodebuild-succeeds-xcresult-unassociated-failure/</guid><description>&lt;p&gt;A branch passed locally. &lt;code&gt;xcodebuild test&lt;/code&gt; exited 0 and printed:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;** TEST SUCCEEDED **&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The fastlane coverage lane on the same branch failed with &lt;code&gt;Tests have failed&lt;/code&gt; and didn&amp;rsquo;t name a test. Scan&amp;rsquo;s xcpretty output doesn&amp;rsquo;t show Swift Testing results, so the log didn&amp;rsquo;t include any failing tests. The result bundle had the answer:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;xcrun xcresulttool get test-results summary --path Test.xcresult&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;{&amp;#39;result&amp;#39;: &amp;#39;Failed&amp;#39;, &amp;#39;totalTestCount&amp;#39;: 2320, &amp;#39;passedTests&amp;#39;: 2317, &amp;#39;failedTests&amp;#39;: 1, ...}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;testName: Issues recorded without an associated test or suite&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;failureText: Issue recorded: failed - Unimplemented: Unimplemented AppVersionClient.currentVersion …&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; This issue was emitted from tests running in a host application.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; This can lead to false positives, where failures could have emitted from live application code at launch time, and not from the current test.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If your console is green and fastlane, Xcode Cloud or anything else that reads the &lt;code&gt;.xcresult&lt;/code&gt; is red, check the summary for that pseudo-test before you go looking for a flaky test.&lt;/p&gt;&#10;&#10;&lt;h2 id="setup"&gt;Setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Xcode 26, Swift Testing, an app-hosted unit test target (tests run inside the real app process).&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://github.com/pointfreeco/swift-dependencies"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;swift-dependencies&lt;/a&gt;&#10; 1.14, with a client whose &lt;code&gt;testValue&lt;/code&gt; calls &lt;code&gt;IssueReporting.unimplemented(...)&lt;/code&gt;. That&amp;rsquo;s the usual way to make sure a test sets every dependency it uses.&lt;/li&gt;&#10;&lt;li&gt;The app&amp;rsquo;s root view is a login screen. On appear, it asks a backend for the minimum supported version and compares it with &lt;code&gt;currentVersion()&lt;/code&gt;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="what-happened"&gt;What happened &lt;/h2&gt;&#10;&lt;p&gt;Because the app hosts the tests, it launches as it does for a user, and its login screen sends the version request. When the response arrives, the screen reads the version client inside a test process, which resolves to the unimplemented test value and records an issue. No test is running on that code path, so the issue gets attached to no test.&lt;/p&gt;&#10;&lt;p&gt;The console run only shows it as a warning:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;⚠︎ Test «unknown» recorded a warning at VersionClient.swift:24:1: Issue recorded&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;↳ failed - Unimplemented: Unimplemented AppVersionClient.currentVersion …&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The result bundle counts it as a failure, though, and fastlane reads it.&lt;/p&gt;&#10;&lt;p&gt;Main didn&amp;rsquo;t fail, and the new branch didn&amp;rsquo;t touch any of this code. Its only addition was a new app-hosted suite that took about seven seconds. That extra time let the response arrive while tests were still running. On main, the run was over before the response came back. This is timing-dependent, so a faster backend or a slower one can hide the failure or bring it back.&lt;/p&gt;&#10;&lt;p&gt;Reproduce one target and compare the result bundle summaries:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Run&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Total&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Failed&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Hosted target, as is&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;21&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;1&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Same, new suite skipped (&lt;code&gt;-skip-testing&lt;/code&gt;)&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;16&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;0&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Same, with the fix&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;20&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;0&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;The 21 in the first row is 20 real tests plus the pseudo-test.&lt;/p&gt;&#10;&#10;&lt;h2 id="dead-end-set-the-dependency-at-launch"&gt;Dead end: set the dependency at launch &lt;/h2&gt;&#10;&lt;p&gt;The obvious fix is to give the app a live value in its entry point:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;prepareDependencies&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;appVersion&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AppVersionClient&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Bundle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shortVersion&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It built and changed nothing. The same failure showed up again. In a test process, swift-dependencies resets its cached values for each test: it registers an XCTest observer that clears the cache whenever a test case starts. The value you prepare at app launch is gone once the tests start, and the later response reads the test value again.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix-dont-show-the-app-ui-while-hosting-tests"&gt;Fix: don&amp;rsquo;t show the app UI while hosting tests &lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;isTesting&lt;/code&gt; from IssueReporting (re-exported by Dependencies) is true inside a test process. Gate the scene&amp;rsquo;s content on it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;Dependencies&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;SwiftUI&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;MyApp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;App&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;some&lt;/span&gt; &lt;span class="n"&gt;Scene&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;WindowGroup&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// Unit tests run inside this app. Its login screen would start a&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// network request whose response reads an unimplemented test dependency.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;isTesting&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;RootView&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This is the workaround in the library&amp;rsquo;s &lt;a&#10; href="https://swiftpackageindex.com/pointfreeco/swift-dependencies/main/documentation/dependencies/testing#Testing-gotchas"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Testing gotchas&lt;/a&gt;&#10; docs. Before applying it, check that no hosted test needs the app&amp;rsquo;s window or a view in the scene. Here, none did, because the tests only needed a real &lt;code&gt;UIApplication&lt;/code&gt;, which still exists with an empty &lt;code&gt;WindowGroup&lt;/code&gt;. The shipping app is unchanged, since &lt;code&gt;isTesting&lt;/code&gt; is false outside a test run.&lt;/p&gt;&#10;&lt;p&gt;With the fix, the hosted target passed 20 of 20, the whole coverage lane reported 0 failures on both schemes, and the test run stopped sending requests to the dev backend.&lt;/p&gt;&#10;&lt;p&gt;That last part is a nice bonus. A test host that shows its real UI talks to real servers from your CI, and anything it logs (request and response bodies, anti-forgery tokens) ends up in your test logs.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;An app-hosted test run also runs your app. Anything the app starts on its own at launch can record a failure that belongs to no test and shows up only in the &lt;code&gt;.xcresult&lt;/code&gt;. Keep the UI out of the test host with &lt;code&gt;if !isTesting&lt;/code&gt;, and when the console and the lane disagree, read &lt;code&gt;xcresulttool get test-results summary&lt;/code&gt; first.&lt;/p&gt;&#10;&lt;p&gt;Try it on your own app-hosted target: grep a recent test log for &lt;code&gt;Test «unknown» recorded&lt;/code&gt;. Each hit is an issue recorded outside any test, and the result bundle may already count it as a failure.&lt;/p&gt;&#10;</description></item><item><title>Waiting for logout before login still left a race inside the handshake</title><link>https://eghuro.cz/posts/logout-wait-race-inside-login-handshake/</link><pubDate>Mon, 21 Sep 2026 14:49:00 +0200</pubDate><guid>https://eghuro.cz/posts/logout-wait-race-inside-login-handshake/</guid><description>&lt;p&gt;Correction 2026-10-31: The earlier version attributed the reported failure to an idle timer. Later source review ruled out that timer for this app. The injected-teardown reproduction and exclusion-gate fix remain valid; the production trigger is unknown.&lt;/p&gt;&#10;&lt;p&gt;Users authenticated with Face ID, and the login failed anyway with a generic &amp;ldquo;login failed&amp;rdquo; error. The password was right, and the biometric enrollment was fine. The server&amp;rsquo;s log for the affected user showed the last handshake step arriving on a session in its initial state, with no user bound to it.&lt;/p&gt;&#10;&lt;p&gt;The app waited for any teardown already running before starting login. A dedicated test injected a new teardown after that wait and reproduced the same server-state signature. Holding an exclusion gate for the whole handshake fixed that reproduction, but did not establish the cause of the reported incident.&lt;/p&gt;&#10;&#10;&lt;h2 id="setup"&gt;Setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;A stateful, multi-step login (SRP-style): start a session, send the identity, send the proof, finish. The server tracks progress per session cookie.&lt;/li&gt;&#10;&lt;li&gt;A &lt;strong&gt;session teardown&lt;/strong&gt;: sign out on the server, then clear the local cookie jar. &lt;code&gt;begin()&lt;/code&gt; returns immediately, and concurrent &lt;code&gt;begin()&lt;/code&gt; calls join one teardown.&lt;/li&gt;&#10;&lt;li&gt;An earlier fix for a related bug: before its handshake, login called &lt;code&gt;waitForCompletion()&lt;/code&gt; so a sign-out still in flight couldn&amp;rsquo;t destroy the new session.&lt;/li&gt;&#10;&lt;li&gt;The reproduction explicitly calls &lt;code&gt;begin()&lt;/code&gt; during the handshake. It does not rely on an idle timer.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;login&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;credential&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;sessionTeardown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;waitForCompletion&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// once, before the handshake&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// four round trips&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="reproduce-with-the-real-stack"&gt;Reproduce with the real stack &lt;/h2&gt;&#10;&lt;p&gt;A dedicated live test drove the production login code against a development server and fired &lt;code&gt;begin()&lt;/code&gt; at a fixed delay into the handshake. At 500 ms:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;start -&amp;gt; {&amp;#34;state&amp;#34;:&amp;#34;new&amp;#34;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;send identity -&amp;gt; {&amp;#34;state&amp;#34;:&amp;#34;identified&amp;#34;, &amp;#34;salt&amp;#34;:...}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;send proof (M1) -&amp;gt; {&amp;#34;state&amp;#34;:&amp;#34;proof-verified&amp;#34;, &amp;#34;M2&amp;#34;:...}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;logout() &amp;lt;- the teardown fires here&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;POST /logout&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;finish (keys sent)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Logout success&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Session teardown: cookie jar cleared&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Error: state &amp;#34;new&amp;#34;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-&amp;gt; failed(.other)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This matched the server-state signature logged for the user. In the reproduction, the one-time wait had nothing to wait for: no teardown was running when login started. The test started one between two round trips.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-trap-inside-the-trap-which-effect-does-the-damage"&gt;The trap inside the trap: which effect does the damage &lt;/h2&gt;&#10;&lt;p&gt;The code comments blamed the &lt;strong&gt;cookie-jar clear&lt;/strong&gt;: the concluding step would go out without a session cookie and land on an anonymous session. That would suggest a small fix: defer only the local cleanup.&lt;/p&gt;&#10;&lt;p&gt;In the reproduction&amp;rsquo;s trace, the cookie jar was cleared &lt;em&gt;after&lt;/em&gt; the finish step had already gone out with valid cookies. The &lt;strong&gt;server sign-out&lt;/strong&gt; reset the half-authenticated session, and the concluding step then arrived at a session the server had put back to &lt;code&gt;new&lt;/code&gt;. Deferring only the jar clear would still fail this exact trace.&lt;/p&gt;&#10;&lt;p&gt;Before you choose what to defer, read the ordering on the wire. The local effect you can see easily may not be the one that broke things.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix-hold-exclusion-for-the-whole-protocol"&gt;Fix: hold exclusion for the whole protocol &lt;/h2&gt;&#10;&lt;p&gt;The teardown gets a gate. Login holds it from the first round trip to the last. A &lt;code&gt;begin()&lt;/code&gt; that arrives while the gate is held is honored but deferred—neither its sign-out nor its cookie clear runs until the handshake releases the gate.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;TeardownState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;inFlight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Void&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Never&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;?&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;handshakes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="c1"&gt;// a count, so overlapping logins can&amp;#39;t release each other&amp;#39;s gate&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;isHeldBack&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;withValue&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inFlight&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// join the running one&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handshakes&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isHeldBack&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inFlight&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;runTeardown&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// clears inFlight when done&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;beginHandshake&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// Wait out a running teardown, then claim the gate. Loop, because the slot&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// is only free on a later pass.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;running&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;withValue&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Void&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Never&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;?&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;t&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inFlight&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handshakes&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;running&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;endHandshake&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;withValue&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handshakes&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handshakes&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isHeldBack&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isHeldBack&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inFlight&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;runTeardown&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;excludingTeardown&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="kc"&gt;_&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;async&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;async&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;beginHandshake&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;endHandshake&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Login wraps every credential type in it, so a new login surface can&amp;rsquo;t skip it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;login&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;credential&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;sessionTeardown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;excludingTeardown&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The other properties stayed the same. &lt;code&gt;begin()&lt;/code&gt; still returns immediately. A second &lt;code&gt;begin()&lt;/code&gt; still means one teardown, whether that teardown is running or held back. A teardown whose sign-out throws still releases its waiters. Logout still happens: it runs right after login, which then ends in a signed-out state on purpose.&lt;/p&gt;&#10;&lt;p&gt;Later source review found that this app created its idle timer only after the full login completed and dropped it when the login screen appeared. The shipped revision had the same wiring. The earlier claim that this timer disproved a signed-in-only assumption was withdrawn. The gate provides exclusion without depending on that reachability argument.&lt;/p&gt;&#10;&lt;p&gt;A sibling app did retain its timer after logout and needed a signed-in guard. That was a separate timer-lifetime defect, not evidence that the timer caused this app&amp;rsquo;s reported failure.&lt;/p&gt;&#10;&#10;&lt;h2 id="verification"&gt;Verification &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Unit test for the new direction.&lt;/strong&gt; A teardown begun inside &lt;code&gt;excludingTeardown&lt;/code&gt; runs neither effect until the body returns. The expected order is &lt;code&gt;[handshake, signOut, clearCookieJar]&lt;/code&gt;. Inside the body, the test awaits &lt;code&gt;waitForCompletion()&lt;/code&gt; rather than &lt;code&gt;Task.yield()&lt;/code&gt;, so a buggy teardown is guaranteed a chance to run. A yield would leave that to the scheduler.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Unit test for the old direction.&lt;/strong&gt; A teardown already running still finishes before the handshake starts. That is the guarantee the earlier fix provided.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Injected-teardown sweep.&lt;/strong&gt; The reproduction fires the teardown at 100, 200, 300, 400, 500 and 700 ms. It had been marked &lt;code&gt;XCTExpectFailure&lt;/code&gt;. After the fix, every delay logged in:&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;teardown at 100ms -&amp;gt; loggedIn&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;teardown at 200ms -&amp;gt; loggedIn&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;teardown at 700ms -&amp;gt; loggedIn&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The expected-failure marker was removed in the same commit.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-this-doesnt-prove"&gt;What this doesn&amp;rsquo;t prove &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;The reproduction establishes a failure mechanism and verifies the exclusion fix. It does not prove that this mechanism caused the reported incident or identify its triggering call. We ruled out the idle timer for this app; the field trigger remained unknown.&lt;/li&gt;&#10;&lt;li&gt;The dedicated reproduction and the unit tests verify the fix. The live test class still exited with failures. They came from a separate host-app dependency problem in the test process, and another test in that class still tracks an unrelated second-login bug. Don&amp;rsquo;t read &amp;ldquo;the sweep passes&amp;rdquo; as &amp;ldquo;the live suite is green&amp;rdquo;.&lt;/li&gt;&#10;&lt;li&gt;The ordinary test plan passed, but live tests are opt-in there, so it says nothing about the server interaction.&lt;/li&gt;&#10;&lt;li&gt;Which delays hit the window depends on latency to the server. A sweep is better than a single delay, but it is still a sample.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="takeaways"&gt;Takeaways &lt;/h2&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Waiting once for cleanup before a multi-step protocol protects only that instant. If cleanup can &lt;em&gt;start&lt;/em&gt; during the protocol, exclude it for the whole protocol.&lt;/li&gt;&#10;&lt;li&gt;Defer cleanup, don&amp;rsquo;t drop it. Queue the request and run it when the gate is released.&lt;/li&gt;&#10;&lt;li&gt;Count holders instead of using a flag, so two overlapping logins can&amp;rsquo;t release the gate from under each other.&lt;/li&gt;&#10;&lt;li&gt;Read the wire ordering before you choose what to defer. Here, the server sign-out did the damage, not the local cookie clear that everyone suspected.&lt;/li&gt;&#10;&lt;li&gt;A matching reproduction does not identify the production trigger. Trace the initiating call in the affected build before naming it as the cause of an incident.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;</description></item></channel></rss>