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: 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..9d3dd4f 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,12 @@ 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 ?? "" + 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() @@ -119,6 +129,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 +197,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 +205,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}"