From 8c7c411c8c548dc988dfc4236daa11b1f00e5956 Mon Sep 17 00:00:00 2001 From: Maxim Averyanov Date: Thu, 27 Aug 2026 17:46:36 +0200 Subject: [PATCH 1/3] Compile the macOS front end, which had never been compiled The macOS job is the first machine to have built it, and it found what was waiting: onChange without an argument is macOS 14 and this targets 13, and a conditional between two implicit member colours has no type to infer from. Both are the kind of thing no amount of reading finds. Three things fixed while the build was red anyway. The view is on the main actor, which is where it was going to end up. main.swift is now MThreadDrawApp.swift, so @main stands on its own and the package needs no unsafe compiler flag to allow it. And standard error is drained rather than left to fill: a pipe nobody reads stops at 64 KB and the writer blocks for ever, so the engine would have hung instead of failing - and its last words are exactly what says why. --- macos/Package.swift | 6 ++- macos/Sources/MThreadDraw/ContentView.swift | 7 ++- macos/Sources/MThreadDraw/Engine.swift | 53 +++++++++++++++---- .../{main.swift => MThreadDrawApp.swift} | 0 tools/github.sh | 7 ++- 5 files changed, 57 insertions(+), 16 deletions(-) rename macos/Sources/MThreadDraw/{main.swift => MThreadDrawApp.swift} (100%) diff --git a/macos/Package.swift b/macos/Package.swift index 5338113..5826807 100644 --- a/macos/Package.swift +++ b/macos/Package.swift @@ -13,10 +13,12 @@ let package = Package( name: "MThreadDraw", platforms: [.macOS(.v13)], targets: [ + // No file here is called main.swift, which is what lets @main in + // MThreadDrawApp.swift stand: a file by that name is top-level code, + // and the two cannot coexist. .executableTarget( name: "MThreadDraw", - path: "Sources/MThreadDraw", - swiftSettings: [.unsafeFlags(["-parse-as-library"])] + path: "Sources/MThreadDraw" ) ] ) diff --git a/macos/Sources/MThreadDraw/ContentView.swift b/macos/Sources/MThreadDraw/ContentView.swift index d9a0fb9..f520257 100644 --- a/macos/Sources/MThreadDraw/ContentView.swift +++ b/macos/Sources/MThreadDraw/ContentView.swift @@ -183,6 +183,7 @@ final class Model: ObservableObject { func stop() { engine.post("stop") } } +@MainActor struct ContentView: View { @StateObject private var model = Model() @@ -227,7 +228,9 @@ struct ContentView: View { Text("Portraits, animals, nature").tag("flow") } .labelsHidden() - .onChange(of: model.tracer) { Task { await model.preview() } } + // The closure takes the new value: the argument-less form of onChange + // is macOS 14, and this app runs on 13. + .onChange(of: model.tracer) { _ in Task { await model.preview() } } slider("How much detail", value: $model.detail, range: 1...10, caption: detailWord(model.detail)) { @@ -294,7 +297,7 @@ struct ContentView: View { .padding(.horizontal, 10).padding(.vertical, 6) .background { RoundedRectangle(cornerRadius: 8) - .fill(index == model.current ? .white.opacity(0.14) : .clear) + .fill(Color.white.opacity(index == model.current ? 0.14 : 0)) } .opacity(layer.visible ? 1 : 0.45) .onTapGesture { Task { await model.run("layer_select", ["index": index]) } } diff --git a/macos/Sources/MThreadDraw/Engine.swift b/macos/Sources/MThreadDraw/Engine.swift index 444ac77..63da5af 100644 --- a/macos/Sources/MThreadDraw/Engine.swift +++ b/macos/Sources/MThreadDraw/Engine.swift @@ -90,7 +90,14 @@ final class Engine { process.arguments = arguments process.standardInput = input process.standardOutput = output - process.standardError = Pipe() + // Drained, not ignored. A pipe nobody reads fills at 64 KB and the + // next write blocks for ever, so the engine would hang rather than + // fail - and the last few lines are what says why it did. + let errors = Pipe() + process.standardError = errors + errors.fileHandleForReading.readabilityHandler = { [weak self] handle in + self?.absorbErrors(handle.availableData) + } if let repository = Engine.findRepository() { process.currentDirectoryURL = repository } @@ -108,9 +115,14 @@ final class Engine { self?.readyContinuation = continuation } } - group.addTask { + group.addTask { [weak self] in try await Task.sleep(for: .seconds(30)) - throw EngineError.notStarted("It produced no output in thirty seconds.") + let detail = self?.lastErrors ?? "" + throw EngineError.notStarted(detail.isEmpty + ? "It produced no output in thirty seconds." + : "It produced no output in thirty seconds. + +\(detail)") } try await group.next() group.cancelAll() @@ -119,6 +131,14 @@ final class Engine { // MARK: - reading + /// The tail of the engine's standard error, for when it dies without a word. + private(set) var lastErrors = "" + + private func absorbErrors(_ data: Data) { + guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return } + lastErrors = String((lastErrors + text).suffix(4000)) + } + private func absorb(_ data: Data) { guard !data.isEmpty else { return } buffer.append(data) @@ -179,10 +199,7 @@ final class Engine { /// Send one request and wait for its reply. @discardableResult func call(_ operation: String, _ arguments: [String: Any] = [:]) async throws -> [String: Any] { - lock.lock() - nextId += 1 - let id = nextId - lock.unlock() + let id = claimId() var request: [String: Any] = arguments request["id"] = id @@ -190,13 +207,29 @@ final class Engine { let line = try JSONSerialization.data(withJSONObject: request) + Data([0x0A]) return try await withCheckedThrowingContinuation { continuation in - lock.lock() - pending[id] = continuation - lock.unlock() + register(id, continuation) input.fileHandleForWriting.write(line) } } + // Both of these exist to be synchronous. Taking a lock directly in an async + // function is a warning today and an error under Swift 6, because the + // thread that resumes after an await need not be the one that took it; + // inside a plain method there is no await to move, so the pair is safe. + private func claimId() -> Int { + lock.lock() + defer { lock.unlock() } + nextId += 1 + return nextId + } + + private func register(_ id: Int, + _ continuation: CheckedContinuation<[String: Any], Error>) { + lock.lock() + defer { lock.unlock() } + pending[id] = continuation + } + /// Send a request without waiting - for stop, which cannot queue. func post(_ operation: String) { let line = "{\"op\": \"\(operation)\"}\n" diff --git a/macos/Sources/MThreadDraw/main.swift b/macos/Sources/MThreadDraw/MThreadDrawApp.swift similarity index 100% rename from macos/Sources/MThreadDraw/main.swift rename to macos/Sources/MThreadDraw/MThreadDrawApp.swift diff --git a/tools/github.sh b/tools/github.sh index 1fa2e67..a8b4de4 100644 --- a/tools/github.sh +++ b/tools/github.sh @@ -55,14 +55,17 @@ if [ -z "$TOKEN" ]; then exit 1 fi +# --location because the log endpoints answer with a redirect to wherever the +# logs are actually stored, and a job log is the only way to see why a build +# failed on a machine you do not have. if [ -n "$BODY" ]; then - curl -sS -X "$METHOD" \ + curl -sS --location -X "$METHOD" \ -H "Authorization: Bearer ${TOKEN}" \ -H "Accept: application/vnd.github+json" \ -d "$BODY" \ "${API}${PATH_UNDER_REPO}" else - curl -sS -X "$METHOD" \ + curl -sS --location -X "$METHOD" \ -H "Authorization: Bearer ${TOKEN}" \ -H "Accept: application/vnd.github+json" \ "${API}${PATH_UNDER_REPO}" From e10b2bb6522f741c29e2445510b244bf93905a4c Mon Sep 17 00:00:00 2001 From: Maxim Averyanov Date: Thu, 27 Aug 2026 17:47:26 +0200 Subject: [PATCH 2/3] Keep the newline a newline The previous commit pushed a Swift string literal with an actual line break in it, which does not compile. Written by a script whose escapes did not survive the trip through the shell. --- macos/Sources/MThreadDraw/Engine.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/macos/Sources/MThreadDraw/Engine.swift b/macos/Sources/MThreadDraw/Engine.swift index 63da5af..9d3dd4f 100644 --- a/macos/Sources/MThreadDraw/Engine.swift +++ b/macos/Sources/MThreadDraw/Engine.swift @@ -118,11 +118,9 @@ final class Engine { group.addTask { [weak self] in try await Task.sleep(for: .seconds(30)) let detail = self?.lastErrors ?? "" - throw EngineError.notStarted(detail.isEmpty - ? "It produced no output in thirty seconds." - : "It produced no output in thirty seconds. - -\(detail)") + let complaint = "It produced no output in thirty seconds." + throw EngineError.notStarted( + detail.isEmpty ? complaint : complaint + "\n\n" + detail) } try await group.next() group.cancelAll() From a55c768df6c3dee28606c6a3eb71df0bcfb61250 Mon Sep 17 00:00:00 2001 From: Maxim Averyanov Date: Thu, 27 Aug 2026 17:59:04 +0200 Subject: [PATCH 3/3] Build the front ends when the front ends change The paths that trigger this workflow listed the packaging scripts but not the two applications they package, so the commit that fixed the macOS build could not run the macOS build. --- .github/workflows/release.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 251f6a7..a349a6c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,11 @@ on: - "tools/build_macos.py" - "tools/make_icon.py" - ".github/workflows/release.yml" + # The front ends themselves. Neither is built by CI, so a change here + # reaches a compiler for the first time in this workflow - and the macOS + # one cannot be built anywhere else in this project at all. + - "macos/**" + - "winui/**" workflow_dispatch: permissions: