← Async Digital

English Cymraeg

Working notes ·

One caller hid the crash

Async Digital Ltd Cardiff, UK

Abstract

A Swift test suite of mine passed seven out of seven while the code underneath it corrupted the task allocator on every run. The defect was real, deterministic, and in the shipping path. It was invisible because only one file in the suite called the initialiser that triggers it, and the bug needs two before it can exist at all. Adding a second test file that makes the same call turned the same suite red, two out of two, against an unchanged library. The toolchain bug underneath is filed as swiftlang/swift#92017. The part I want to keep is the test result.

I have spent this year writing about what breaks when you hand real work to AI agents. This note is not about agents. It is about a green test suite that could not have gone red, which is the same shape as most of what I write here and turned up in plain Swift.

§1·Abort

The message that is not yours to cause

A small package of mine serialises competing UI, the kind where a deep link, an alert and a connectivity banner all want the screen at once. It started aborting under test with a message from the Swift runtime: freed pointer was not the last allocation.

That comes out of swift_task_dealloc, which is the concurrency runtime tearing down an async frame. My code does not call it. So either I had found a toolchain bug, which is the explanation you should distrust first and hardest, or I had done something to deserve it.

It was the first one. The evidence is below, and it is the less interesting half of this note.

§2·Default

Two copies of one closure

The package has a manager that pauses between queued items. The sleep is injected so tests can drive it with a fake clock, and the real clock sat in a default argument:

public init(sleep: @escaping @Sendable (TimeInterval) async throws -> Void
            = { try await Task.sleep(for: .seconds($0)) }) {
    self.sleep = sleep
}

That is an ordinary shape. It is also the whole bug.

Every test that injected a sleep passed. Passing the identical closure explicitly at the call site passed, with real elapsed time on the clock. Task.sleep(nanoseconds:) passed. A free async function passed. Only the default-argument version aborted, and it aborted on every rebuild, ten times out of ten. So the discriminator is being a default argument, not sleeping.

Default-argument expressions of public functions compile into the caller. The closure therefore exists twice: once in the library that declares it, once in every module that imports the library and uses the default. Both copies are emitted as link-once symbols, and the linker is entitled to keep either one.

At -Onone the two copies had different async frame layouts. The library copy used a 128 byte context and spilled the error register at offset 0x70. The client copy used 112 bytes and spilled the same register at 0x68, with every field sitting eight bytes lower. Two layouts, not one layout with a gap in it.

The linker took the body from the library object and the async function pointer, which is the record carrying the context size, from the client object. Every call then allocated 112 bytes for code that writes at 0x70. And 0x70 is 112, so the write landed exactly one word past the end of the allocation.

§3·Proof

Catching the write

What sits one word past the end is the header of the next task allocation. I confirmed that rather than inferring it, with a DYLD_INSERT_LIBRARIES interposer on swift_task_alloc and swift_task_dealloc that checked every header as it went past.

In the crashing task the sequence is a 112 byte pause context, a 112 byte closure context, a zero byte clock buffer, then a 32 byte Task.sleep context. The zero byte buffer’s header sits at closure context plus 0x70. Its previous link was correct when it was allocated and zero by the time the closure freed the sleep context. The runtime aborts at the next dealloc because the free list no longer makes sense.

One more check, because a mechanism you can only describe is a mechanism you might have invented. I relinked the identical objects with the file list reversed. The linker then took the client’s body together with the client’s record, the two agreed, and every previously crashing test passed. Same compiler, same objects, different order.

Release builds were green throughout. Both copies optimise down to a 64 byte frame, the records agree, and the defect is gone. Worth saying out loud: a release-only CI job would never have seen this.

§4·Blind

The test that was built to catch this, and could not

Here is the state I was actually in before any of that started.

The suite had seven tests and they passed. One of them was a real-clock regression test that existed specifically to exercise the default initialiser, which is to say it existed to catch this class of defect. It passed too, and it passed honestly. The binary it ran against was linked clean.

It was linked clean because it was the only client. With one importing object using the default argument, the linker takes the body and the frame record from the same place, the two agree, and there is nothing to mismatch. The bug needs two importing objects before it exists.

So I added a second test file that also calls the default initialiser and does nothing else of interest. The same suite, against the same unfixed library, aborted two out of two. After the fix it passed eight out of eight.

The ingredient is not a test. It is a second caller. A suite can hold a test written to catch a defect, run it on every commit for months, and be structurally incapable of reaching it. Nothing about that test was weak. It was starved, which is the same thing I ran into in a memory pipeline a fortnight ago and did not expect to meet again in a linker.

§5·Traps

Two smaller things that told me nothing

The demo executable ran fine the whole time. It carries the identical mismatch and overruns by one word on every real sleep. In its allocation layout the overrun lands on slack instead of a live header, so nothing complains. It was not evidence that the library was healthy. It was silent corruption with a friendly face, and I had been reading it as a positive control.

Several of my own early passes were void. Constructing the manager as a temporary and dispatching on it lets the manager deallocate before the task runs. The weak capture is nil, nothing sleeps, and the test reports success in about a millisecond. A test that passes in 1 ms when the work takes 25 ms has not passed. I re-ran everything holding the manager and asserting elapsed time.

§6·Keep

The fix, and the thing I get to keep

Drop the default and put the closure inside the library:

public init(sleep: @escaping @Sendable (TimeInterval) async throws -> Void) {
    self.sleep = sleep
}
public convenience init() {
    self.init(sleep: { try await Task.sleep(for: .seconds($0)) })
}

One copy of the closure exists, so there is nothing to mismatch, and call sites are unchanged. The rule I work to now is narrower than it sounds: no closure literals in the public default arguments of async seams.

For anyone who arrives here having searched the abort message, the trigger shape is a public initialiser on a generic class whose default argument is an async closure literal, called from another module, in a debug build. It is filed as swiftlang/swift#92017. Two existing reports, #86204 and #88794, show the same abort without a diagnosis, which suggests the shape is not rare.

The bug will be fixed by people who are not me. What I get to keep is the test result, and the test result was that a green suite told me nothing, for a reason with no connection to the quality of the test.

I work to a rule now about anything I have installed to catch something: a passing check is only information if I have seen that check fail. I had never seen that test go red. Had I tried once, on purpose, to make it fail, I would have found out in an afternoon that it could not.

Method how this note was made

The incident is mine and it happened. The layouts, offsets and counts in §2 and §3 are read off my own objects and binaries on Swift 6.2.4 (swiftlang-6.2.4.1.4), Xcode 26.3, ld-1230.1, macOS 26.5.2, arm64. Debug builds only.

The upstream report carries a reproduction with none of my code in it, so the mechanism can be checked without taking my word for the package it turned up in.

I worked on this with an AI agent, as I do on most things now. The relink check and the second-caller test were mine to ask for; the disassembly reading was not something I would have done by hand.