From 44c02c6d847ed71457ff7896e88b202417449c97 Mon Sep 17 00:00:00 2001 From: onevtail Date: Mon, 28 Sep 2026 19:31:11 +0900 Subject: [PATCH 1/2] Fix CanvasLayoutStore teardown on older Swift runtimes --- .../000-plan.md | 48 ++++++++++++ .../001-action.md | 74 +++++++++++++++++++ docs-ai/README.md | 1 + .../Canvas/Models/CanvasCardLayout.swift | 4 + supacodeTests/CanvasLayoutStoreTests.swift | 33 +++++++++ 5 files changed, 160 insertions(+) create mode 100644 docs-ai/073-canvas-layout-store-deinit/000-plan.md create mode 100644 docs-ai/073-canvas-layout-store-deinit/001-action.md diff --git a/docs-ai/073-canvas-layout-store-deinit/000-plan.md b/docs-ai/073-canvas-layout-store-deinit/000-plan.md new file mode 100644 index 000000000..35160bcf3 --- /dev/null +++ b/docs-ai/073-canvas-layout-store-deinit/000-plan.md @@ -0,0 +1,48 @@ +# 073 — Canvas Layout Store Deinitialization: Plan + +| | | +| --- | --- | +| **Status** | Implemented (local; pending publication) | +| **Anchor date** | 2026-09-28 | +| **Primary PRs** | Pending Relay publication | +| **Related** | [005 canvas](../005-canvas-live-sessions/000-plan.md), tracker onevcat/relay-tracker#333 | + +## Background + +The KB/KC investigation traced store destruction during worktree switching/archiving +through `swift_task_deinitOnExecutor` into a TaskLocal invalid free on macOS 26.3.1. +Swift [#85204](https://github.com/swiftlang/swift/pull/85204) fixes allocation in the +concurrency runtime; updating the compiler does not replace the user's system runtime. + +The public Prowl 2026.9.25 artifact identifies Xcode 27 (`27A266a`) as its build tool. +Its arm64 `CanvasLayoutStore` destructor still calls `swift_task_deinitOnExecutor`. +The application links the system `/usr/lib/swift/libswift_Concurrency.dylib`. + +## Goals + +- Avoid this runtime entry point when releasing `CanvasLayoutStore`. +- Keep main-actor isolation of observable state and existing persistence behavior. +- Exercise synchronous release under task-local storage outside a Swift Task. + +## Design / Approach + +Add an explicit empty `nonisolated deinit` to `CanvasLayoutStore` in +`supacode/Features/Canvas/Models/CanvasCardLayout.swift`. The store owns values, +`UserDefaults`, and the observation registrar; it has no actor-bound teardown work. +Persistence already occurs on mutation, not on destruction. + +Add a focused test in `supacodeTests/CanvasLayoutStoreTests.swift`. Dispatch onto the +main queue to escape the test runner's Swift Task, verify there is no current task, +establish task-local storage, release the store, and verify lifetime and persistence. +Use a standalone extraction of production source to compare old/new simulator runtimes +and inspect generated SIL. Simulator evidence corroborates the runtime defect but does +not substitute for macOS 26.3.1 UI regression. + +## Alternatives & decisions + +- Rely on Xcode 27: rejected because the current Xcode 27 artifact retains the call. +- Raise the minimum OS: unnecessarily drops supported users for a local workaround. +- Remove main-actor isolation or change every isolated deinit: broadens the change and + risks invalid teardown elsewhere. Limit the mitigation to the confirmed store. + +## Amendments diff --git a/docs-ai/073-canvas-layout-store-deinit/001-action.md b/docs-ai/073-canvas-layout-store-deinit/001-action.md new file mode 100644 index 000000000..817d609a0 --- /dev/null +++ b/docs-ai/073-canvas-layout-store-deinit/001-action.md @@ -0,0 +1,74 @@ +# 073 — Canvas Layout Store Deinitialization: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-09-28 | Verified the Xcode 27 release artifact's store destructor; implemented the explicit nonisolated destructor and synchronous TaskLocal regression test | Local branch `fix/canvas-layout-store-deinit`; remote publication pending Relay | + +## Outcome & current state (as of 2026-09-28) + +`CanvasLayoutStore` in `supacode/Features/Canvas/Models/CanvasCardLayout.swift` now has +an explicit empty `nonisolated deinit`. Keep it even though it performs no cleanup: +removing it reintroduces the compiler-inferred isolated destructor under the project's +default main-actor isolation. State access remains main-actor isolated. The store does +not need main-actor work during teardown; persistence remains in property observers. + +`supacodeTests/CanvasLayoutStoreTests.swift` adds release coverage in a main-queue +callback with no current Swift Task and a live TaskLocal binding. It checks synchronous +lifetime completion, preservation/restoration of the binding, and persistence after +release. On a fixed system runtime the original code can also pass; the affected-runtime +comparison and compiler-output inspection establish the workaround's necessity. + +### Why Xcode 27 alone does not resolve this path + +The public [2026.9.25 release](https://github.com/onevcat/Prowl/releases/tag/v2026.9.25) +records `DTXcode=2700`, `DTXcodeBuild=27A266a`, and `DTSDKName=macosx27.0`. +Its arm64 executable UUID is `D4B8E8A4-DAE3-32CD-8F1F-1714D93E9D62`. +The downloaded ZIP SHA-256 is +`962ce57aaccb74b3cb37409c32da1c918753dd51f0d5d275b72bbc9534bd117d`. + +For this artifact, Swift metadata identifies `CanvasLayoutStore` at `0x101c503f0`. +The singleton metadata template is `0x1021169e8`; its heap destructor slot at +`0x1021169d8` rebases to `0x1004332c8`. Disassembly of that function ends in a branch +at `0x100433338` to the imported `swift_task_deinitOnExecutor`. The binary links the +system `/usr/lib/swift/libswift_Concurrency.dylib`; it does not bundle a replacement. +Thus the current Xcode 27 release still reaches the old system runtime's failing path. + +Swift [#85204](https://github.com/swiftlang/swift/pull/85204) changes TaskLocal runtime +allocation, not the application's generated destructor. Swift +[#88036](https://github.com/swiftlang/swift/issues/88036) documents the matching +synchronous task-local release failure and the distinction between compiler and runtime. + +### Validation + +- Extracted the production store, its payload, and layout value type, adding only the + explicit `Observation` import normally supplied by the app's module imports. +- Xcode 26.6 / Swift 6.3.3, optimized simulator executable: original source aborts on + iOS 26.2 with `StopLookupScope` / `swift_task_deinitOnExecutorImpl` / invalid-free + frames. The patched version completes 10,000 synchronous releases outside a Task. +- The same original and patched executables each complete 10,000 releases on iOS 26.4. +- Xcode 26.3 and 26.6 SIL: the original destructor calls `swift_task_deinitOnExecutor`; + the patched destructor does not. +- `make check` passes, including 210 Python script tests, formatting, lint, workflow + naming, and string-catalog validation. +- `make build-app` passes with Xcode 26.6 (0 errors, 0 warnings). +- `CanvasLayoutStoreTests`: 5 tests pass, zero failures or skips, confirmed from the + result bundle. The final test source also avoids the weak-variable mutability warning. + +## Deviations from plan + +No Xcode 27 installation is available on the investigation host. Its actual published +artifact was inspected instead. The host runs macOS 26.5.2, so affected-runtime execution +uses iOS simulators rather than macOS 26.3.1. Xcode 26.6 selects versioned dependency +manifests for its older Swift toolchain, replacing the `swift-issue-reporting` pin with +`xctest-dynamic-overlay` during local validation. The generated lockfile change is not +part of the fix; release-toolchain validation must use the repository's original pins. + +## Open questions + +- Run switching/archiving regression on macOS 26.3.1 and verify the patched build with + the release Xcode 27 toolchain before publication. No claim of a full macOS 26.3.1 + app-level reproduction is made. +- This mitigation covers KB/KC's confirmed store only. It does not resolve KD's + separate input-method crash or justify changing other classes' teardown isolation. diff --git a/docs-ai/README.md b/docs-ai/README.md index e7e62df77..ce444051a 100644 --- a/docs-ai/README.md +++ b/docs-ai/README.md @@ -132,3 +132,4 @@ agent-facing manual for that). | 070 | [app-localization](070-app-localization/000-plan.md) | 2026-09-18 | Simplified Chinese UI from a String Catalog; translations sync at release time through the `sync-l10n` skill (nothing blocks everyday work); compiler-based coverage audit plus a baseline for unlocalized copy; living [glossary](070-app-localization/glossary.md); language choice stored only in the per-app `AppleLanguages` default | | 071 | [git-environment-recovery](071-git-environment-recovery/000-plan.md) | 2026-09-20 | Working Git selection, safe repository classification, recovery guidance, and plain-folder Shelf entry | | 072 | [workspace-editing](072-workspace-editing/000-plan.md) | 2026-09-25 | Edit a workspace after creation: metadata, member repositories (add, remove, reorder, roles) through one editor sheet reached from the sidebar, detail view, Settings, palette, and menu | +| 073 | [canvas-layout-store-deinit](073-canvas-layout-store-deinit/000-plan.md) | 2026-09-28 | Avoid the older Swift TaskLocal runtime crash during canvas store destruction; Xcode 27 artifact verification | diff --git a/supacode/Features/Canvas/Models/CanvasCardLayout.swift b/supacode/Features/Canvas/Models/CanvasCardLayout.swift index 131e50cd4..b8e3a20a5 100644 --- a/supacode/Features/Canvas/Models/CanvasCardLayout.swift +++ b/supacode/Features/Canvas/Models/CanvasCardLayout.swift @@ -388,6 +388,10 @@ final class CanvasLayoutStore { initiallyLoadedCardKeys = Set(stored.cardLayouts.keys) } + // No actor-bound cleanup is needed. Avoid inferred isolated deinit, which can + // hit the TaskLocal invalid-free bug in older system Swift runtimes (swiftlang/swift#85204). + nonisolated deinit {} + func shouldAutoArrangeOnInitialEntry(for cardKeys: [String]) -> Bool { guard !cardKeys.isEmpty else { return false } return !cardKeys.contains { initiallyLoadedCardKeys.contains($0) } diff --git a/supacodeTests/CanvasLayoutStoreTests.swift b/supacodeTests/CanvasLayoutStoreTests.swift index 09febcca1..26b579197 100644 --- a/supacodeTests/CanvasLayoutStoreTests.swift +++ b/supacodeTests/CanvasLayoutStoreTests.swift @@ -6,6 +6,35 @@ import Testing @MainActor struct CanvasLayoutStoreTests { + @Test func releasesSynchronouslyWithTaskLocalStorageOutsideATask() async { + // Swift Testing runs in a Task. A main-queue callback reproduces synchronous + // UI teardown with thread-local TaskLocal storage instead of task-owned storage. + await withCheckedContinuation { continuation in + DispatchQueue.main.async { + MainActor.assumeIsolated { + #expect(Thread.isMainThread) + withUnsafeCurrentTask { #expect($0 == nil) } + let defaults = makeDefaults() + defer { defaults.removePersistentDomain(forName: defaultsSuiteName(defaults)) } + let layouts = ["tab-a": CanvasCardLayout(position: CGPoint(x: 10, y: 20))] + + CanvasLayoutStoreTestLocal.$marker.withValue(1) { + var store: CanvasLayoutStore? = CanvasLayoutStore(defaults: defaults) + weak let releasedStore = store + store?.setCardLayouts(layouts) + store = nil + + #expect(releasedStore == nil) + #expect(CanvasLayoutStoreTestLocal.marker == 1) + #expect(CanvasLayoutStore(defaults: defaults).cardLayouts == layouts) + } + #expect(CanvasLayoutStoreTestLocal.marker == 0) + continuation.resume() + } + } + } + } + @Test func loadsLegacyCardLayoutDictionary() throws { let defaults = makeDefaults() defer { defaults.removePersistentDomain(forName: defaultsSuiteName(defaults)) } @@ -76,6 +105,10 @@ struct CanvasLayoutStoreTests { } } +private enum CanvasLayoutStoreTestLocal { + @TaskLocal static var marker = 0 +} + private func makeDefaults() -> UserDefaults { let suiteName = "CanvasLayoutStoreTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! From 5814d02a8d7fc3949c2ac4ee0d49029b5b90609f Mon Sep 17 00:00:00 2001 From: onevtail Date: Mon, 28 Sep 2026 23:51:13 +0900 Subject: [PATCH 2/2] Remove the docs-ai entry for the CanvasLayoutStore fix This fix is too small for a durable docs-ai record. The pull request keeps the rationale. --- .../000-plan.md | 48 ------------ .../001-action.md | 74 ------------------- docs-ai/README.md | 1 - 3 files changed, 123 deletions(-) delete mode 100644 docs-ai/073-canvas-layout-store-deinit/000-plan.md delete mode 100644 docs-ai/073-canvas-layout-store-deinit/001-action.md diff --git a/docs-ai/073-canvas-layout-store-deinit/000-plan.md b/docs-ai/073-canvas-layout-store-deinit/000-plan.md deleted file mode 100644 index 35160bcf3..000000000 --- a/docs-ai/073-canvas-layout-store-deinit/000-plan.md +++ /dev/null @@ -1,48 +0,0 @@ -# 073 — Canvas Layout Store Deinitialization: Plan - -| | | -| --- | --- | -| **Status** | Implemented (local; pending publication) | -| **Anchor date** | 2026-09-28 | -| **Primary PRs** | Pending Relay publication | -| **Related** | [005 canvas](../005-canvas-live-sessions/000-plan.md), tracker onevcat/relay-tracker#333 | - -## Background - -The KB/KC investigation traced store destruction during worktree switching/archiving -through `swift_task_deinitOnExecutor` into a TaskLocal invalid free on macOS 26.3.1. -Swift [#85204](https://github.com/swiftlang/swift/pull/85204) fixes allocation in the -concurrency runtime; updating the compiler does not replace the user's system runtime. - -The public Prowl 2026.9.25 artifact identifies Xcode 27 (`27A266a`) as its build tool. -Its arm64 `CanvasLayoutStore` destructor still calls `swift_task_deinitOnExecutor`. -The application links the system `/usr/lib/swift/libswift_Concurrency.dylib`. - -## Goals - -- Avoid this runtime entry point when releasing `CanvasLayoutStore`. -- Keep main-actor isolation of observable state and existing persistence behavior. -- Exercise synchronous release under task-local storage outside a Swift Task. - -## Design / Approach - -Add an explicit empty `nonisolated deinit` to `CanvasLayoutStore` in -`supacode/Features/Canvas/Models/CanvasCardLayout.swift`. The store owns values, -`UserDefaults`, and the observation registrar; it has no actor-bound teardown work. -Persistence already occurs on mutation, not on destruction. - -Add a focused test in `supacodeTests/CanvasLayoutStoreTests.swift`. Dispatch onto the -main queue to escape the test runner's Swift Task, verify there is no current task, -establish task-local storage, release the store, and verify lifetime and persistence. -Use a standalone extraction of production source to compare old/new simulator runtimes -and inspect generated SIL. Simulator evidence corroborates the runtime defect but does -not substitute for macOS 26.3.1 UI regression. - -## Alternatives & decisions - -- Rely on Xcode 27: rejected because the current Xcode 27 artifact retains the call. -- Raise the minimum OS: unnecessarily drops supported users for a local workaround. -- Remove main-actor isolation or change every isolated deinit: broadens the change and - risks invalid teardown elsewhere. Limit the mitigation to the confirmed store. - -## Amendments diff --git a/docs-ai/073-canvas-layout-store-deinit/001-action.md b/docs-ai/073-canvas-layout-store-deinit/001-action.md deleted file mode 100644 index 817d609a0..000000000 --- a/docs-ai/073-canvas-layout-store-deinit/001-action.md +++ /dev/null @@ -1,74 +0,0 @@ -# 073 — Canvas Layout Store Deinitialization: Action Log - -## Timeline - -| Date | Change | Ref | -| --- | --- | --- | -| 2026-09-28 | Verified the Xcode 27 release artifact's store destructor; implemented the explicit nonisolated destructor and synchronous TaskLocal regression test | Local branch `fix/canvas-layout-store-deinit`; remote publication pending Relay | - -## Outcome & current state (as of 2026-09-28) - -`CanvasLayoutStore` in `supacode/Features/Canvas/Models/CanvasCardLayout.swift` now has -an explicit empty `nonisolated deinit`. Keep it even though it performs no cleanup: -removing it reintroduces the compiler-inferred isolated destructor under the project's -default main-actor isolation. State access remains main-actor isolated. The store does -not need main-actor work during teardown; persistence remains in property observers. - -`supacodeTests/CanvasLayoutStoreTests.swift` adds release coverage in a main-queue -callback with no current Swift Task and a live TaskLocal binding. It checks synchronous -lifetime completion, preservation/restoration of the binding, and persistence after -release. On a fixed system runtime the original code can also pass; the affected-runtime -comparison and compiler-output inspection establish the workaround's necessity. - -### Why Xcode 27 alone does not resolve this path - -The public [2026.9.25 release](https://github.com/onevcat/Prowl/releases/tag/v2026.9.25) -records `DTXcode=2700`, `DTXcodeBuild=27A266a`, and `DTSDKName=macosx27.0`. -Its arm64 executable UUID is `D4B8E8A4-DAE3-32CD-8F1F-1714D93E9D62`. -The downloaded ZIP SHA-256 is -`962ce57aaccb74b3cb37409c32da1c918753dd51f0d5d275b72bbc9534bd117d`. - -For this artifact, Swift metadata identifies `CanvasLayoutStore` at `0x101c503f0`. -The singleton metadata template is `0x1021169e8`; its heap destructor slot at -`0x1021169d8` rebases to `0x1004332c8`. Disassembly of that function ends in a branch -at `0x100433338` to the imported `swift_task_deinitOnExecutor`. The binary links the -system `/usr/lib/swift/libswift_Concurrency.dylib`; it does not bundle a replacement. -Thus the current Xcode 27 release still reaches the old system runtime's failing path. - -Swift [#85204](https://github.com/swiftlang/swift/pull/85204) changes TaskLocal runtime -allocation, not the application's generated destructor. Swift -[#88036](https://github.com/swiftlang/swift/issues/88036) documents the matching -synchronous task-local release failure and the distinction between compiler and runtime. - -### Validation - -- Extracted the production store, its payload, and layout value type, adding only the - explicit `Observation` import normally supplied by the app's module imports. -- Xcode 26.6 / Swift 6.3.3, optimized simulator executable: original source aborts on - iOS 26.2 with `StopLookupScope` / `swift_task_deinitOnExecutorImpl` / invalid-free - frames. The patched version completes 10,000 synchronous releases outside a Task. -- The same original and patched executables each complete 10,000 releases on iOS 26.4. -- Xcode 26.3 and 26.6 SIL: the original destructor calls `swift_task_deinitOnExecutor`; - the patched destructor does not. -- `make check` passes, including 210 Python script tests, formatting, lint, workflow - naming, and string-catalog validation. -- `make build-app` passes with Xcode 26.6 (0 errors, 0 warnings). -- `CanvasLayoutStoreTests`: 5 tests pass, zero failures or skips, confirmed from the - result bundle. The final test source also avoids the weak-variable mutability warning. - -## Deviations from plan - -No Xcode 27 installation is available on the investigation host. Its actual published -artifact was inspected instead. The host runs macOS 26.5.2, so affected-runtime execution -uses iOS simulators rather than macOS 26.3.1. Xcode 26.6 selects versioned dependency -manifests for its older Swift toolchain, replacing the `swift-issue-reporting` pin with -`xctest-dynamic-overlay` during local validation. The generated lockfile change is not -part of the fix; release-toolchain validation must use the repository's original pins. - -## Open questions - -- Run switching/archiving regression on macOS 26.3.1 and verify the patched build with - the release Xcode 27 toolchain before publication. No claim of a full macOS 26.3.1 - app-level reproduction is made. -- This mitigation covers KB/KC's confirmed store only. It does not resolve KD's - separate input-method crash or justify changing other classes' teardown isolation. diff --git a/docs-ai/README.md b/docs-ai/README.md index ce444051a..e7e62df77 100644 --- a/docs-ai/README.md +++ b/docs-ai/README.md @@ -132,4 +132,3 @@ agent-facing manual for that). | 070 | [app-localization](070-app-localization/000-plan.md) | 2026-09-18 | Simplified Chinese UI from a String Catalog; translations sync at release time through the `sync-l10n` skill (nothing blocks everyday work); compiler-based coverage audit plus a baseline for unlocalized copy; living [glossary](070-app-localization/glossary.md); language choice stored only in the per-app `AppleLanguages` default | | 071 | [git-environment-recovery](071-git-environment-recovery/000-plan.md) | 2026-09-20 | Working Git selection, safe repository classification, recovery guidance, and plain-folder Shelf entry | | 072 | [workspace-editing](072-workspace-editing/000-plan.md) | 2026-09-25 | Edit a workspace after creation: metadata, member repositories (add, remove, reorder, roles) through one editor sheet reached from the sidebar, detail view, Settings, palette, and menu | -| 073 | [canvas-layout-store-deinit](073-canvas-layout-store-deinit/000-plan.md) | 2026-09-28 | Avoid the older Swift TaskLocal runtime crash during canvas store destruction; Xcode 27 artifact verification |