Babel: show errors, cancel runs !62

merged merged by cmc on 2026-10-06 00:20 UTC · krz/orgstar:fix-babel-errors into main

7 files changed, +101 −7

Layout: unified · split

Sources/OrgApp/BabelRunner.swift +7 −2
@@ -43,9 +43,12 @@ public enum BabelRunner {
43 let stdout = folder.appendingPathComponent("stdout") 43 let stdout = folder.appendingPathComponent("stdout")
44 FileManager.default.createFile(atPath: stdout.path, contents: nil) 44 FileManager.default.createFile(atPath: stdout.path, contents: nil)
45 let out = try FileHandle(forWritingTo: stdout) 45 let out = try FileHandle(forWritingTo: stdout)
46 let stderr = folder.appendingPathComponent("stderr")
47 FileManager.default.createFile(atPath: stderr.path, contents: nil)
48 let err = try FileHandle(forWritingTo: stderr)
46 process.standardInput = stdin 49 process.standardInput = stdin
47 process.standardOutput = out 50 process.standardOutput = out
48 process.standardError = FileHandle.nullDevice 51 process.standardError = err
49 do { 52 do {
50 try process.run() 53 try process.run()
51 } catch { 54 } catch {
@@ -76,11 +79,13 @@ public enum BabelRunner {
76 } 79 }
77 } 80 }
78 try out.close() 81 try out.close()
82 try err.close()
79 let output = (try? String(contentsOf: stdout, encoding: .utf8)) ?? "" 83 let output = (try? String(contentsOf: stdout, encoding: .utf8)) ?? ""
84 let errors = (try? String(contentsOf: stderr, encoding: .utf8)) ?? ""
80 let result = try? String(contentsOf: resultFile, encoding: .utf8) 85 let result = try? String(contentsOf: resultFile, encoding: .utf8)
81 if case .process(let executable, _) = job.runner, process.terminationStatus == 127, output.isEmpty { 86 if case .process(let executable, _) = job.runner, process.terminationStatus == 127, output.isEmpty {
82 throw Failure(description: "\(executable) isn't installed.") 87 throw Failure(description: "\(executable) isn't installed.")
83 } 88 }
84 return BabelOutput(stdout: output, resultFile: result) 89 return BabelOutput(stdout: output, resultFile: result, stderr: errors, status: process.terminationStatus)
85 } 90 }
86} 91}
Sources/OrgApp/DocumentSession.swift +30 −2
@@ -536,21 +536,49 @@ public final class DocumentSession {
536 guard entries.contains(where: { $0 === entry }) else { return "The file was closed before the block finished." } 536 guard entries.contains(where: { $0 === entry }) else { return "The file was closed before the block finished." }
537 let current = entry.buffer 537 let current = entry.buffer
538 let document = current.document 538 let document = current.document
539 let done = output.problem ?? "Code block evaluation complete."
539 switch Babel.finish(job, output: output, text: document.text, settings: document.tree.settings) { 540 switch Babel.finish(job, output: output, text: document.text, settings: document.tree.settings) {
540 case .failure(.message(let message)): 541 case .failure(.message(let message)):
541 return message 542 return message
542 case .success(let edits): 543 case .success(let edits):
543 guard !edits.isEmpty else { return job.finish.params.contains("silent") ? output.stdout : "Code block evaluation complete." } 544 guard !edits.isEmpty else { return job.finish.params.contains("silent") ? output.problem ?? output.stdout : done }
544 let result = EditResult(baseRevision: document.revision, edits: edits) 545 let result = EditResult(baseRevision: document.revision, edits: edits)
545 if case .failed(let message) = current.perform(PrecomputedEdit(result), now: Date(), answers: [:]) { return message } 546 if case .failed(let message) = current.perform(PrecomputedEdit(result), now: Date(), answers: [:]) { return message }
546 changed(entry) 547 changed(entry)
547 return "Code block evaluation complete." 548 return done
548 } 549 }
550 } catch is CancellationError {
551 return "Code block canceled."
549 } catch { 552 } catch {
550 return "\(error)" 553 return "\(error)"
551 } 554 }
552 } 555 }
553 556
557 /// The block running now, to cancel.
558 @ObservationIgnored private var babelTask: Task<String, Never>?
559 public private(set) var isRunningBabel = false
560
561 /// Runs a src block in the background; its message comes back through `report`.
562 public func startBabel(_ job: BabelJob, report: @escaping @MainActor (String) -> Void) {
563 babelTask?.cancel()
564 isRunningBabel = true
565 let task = Task { await runBabel(job) }
566 babelTask = task
567 Task {
568 let message = await task.value
569 if babelTask == task {
570 babelTask = nil
571 isRunningBabel = false
572 }
573 report(message)
574 }
575 }
576
577 /// Stops the running block; its results aren't inserted.
578 public func cancelBabel() {
579 babelTask?.cancel()
580 }
581
554 /// A buffer changed outside typing: the current one updates its views, another saves. 582 /// A buffer changed outside typing: the current one updates its views, another saves.
555 private func changed(_ entry: Entry) { 583 private func changed(_ entry: Entry) {
556 if entry === current { bufferChanged() } else { scheduleAutosave(entry); refreshLists() } 584 if entry === current { bufferChanged() } else { scheduleAutosave(entry); refreshLists() }
Sources/OrgCore/Compute/Babel.swift +18 −1
@@ -42,9 +42,26 @@ public struct BabelJob: Sendable, Equatable {
42public struct BabelOutput: Sendable, Equatable { 42public struct BabelOutput: Sendable, Equatable {
43 public var stdout: String 43 public var stdout: String
44 public var resultFile: String? 44 public var resultFile: String?
45 public init(stdout: String, resultFile: String? = nil) { 45 /// What the program wrote to standard error, and its exit status: Emacs shows them in
46 /// `*Org-Babel Error Output*` and still inserts the results.
47 public var stderr: String
48 public var status: Int32
49 public init(stdout: String, resultFile: String? = nil, stderr: String = "", status: Int32 = 0) {
46 self.stdout = stdout 50 self.stdout = stdout
47 self.resultFile = resultFile 51 self.resultFile = resultFile
52 self.stderr = stderr
53 self.status = status
54 }
55
56 /// A line for the message area when the program failed or complained, else nil.
57 public var problem: String? {
58 let first = stderr.split(separator: "\n", omittingEmptySubsequences: true).first.map(String.init)
59 switch (status, first) {
60 case (0, nil): return nil
61 case (0, let line?): return line
62 case (let code, nil): return "Exited with status \(code)"
63 case (let code, let line?): return "Exited with status \(code): \(line)"
64 }
48 } 65 }
49} 66}
50 67
Sources/OrgEditorAppKit/EditorCommands.swift +1
@@ -48,6 +48,7 @@ public enum EditorCommands {
48 EditorCommand(id: "app.buffer.next", title: "Next Buffer", action: .app), 48 EditorCommand(id: "app.buffer.next", title: "Next Buffer", action: .app),
49 EditorCommand(id: "app.buffer.previous", title: "Previous Buffer", action: .app), 49 EditorCommand(id: "app.buffer.previous", title: "Previous Buffer", action: .app),
50 EditorCommand(id: "app.buffer.last", title: "Last Buffer", action: .app), 50 EditorCommand(id: "app.buffer.last", title: "Last Buffer", action: .app),
51 EditorCommand(id: "app.babel.cancel", title: "Cancel Running Block", action: .app),
51 EditorCommand(id: "app.toggle-tab-bar", title: "Show or Hide Tab Bar", action: .app), 52 EditorCommand(id: "app.toggle-tab-bar", title: "Show or Hide Tab Bar", action: .app),
52 EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app), 53 EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app),
53 EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app), 54 EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app),
Sources/Orgstar/ContentView.swift +4 −2
@@ -358,8 +358,8 @@ struct ContentView: View {
358 } 358 }
359 case .babel(let job): 359 case .babel(let job):
360 let run = { 360 let run = {
361 commands.show("Running \(job.language) block…") 361 commands.show("Running \(job.language) block… (⌘. to cancel)")
362 Task { commands.show(await session.runBabel(job)) } 362 session.startBabel(job) { commands.show($0) }
363 } 363 }
364 guard let path = session.path else { return } 364 guard let path = session.path else { return }
365 if !job.alwaysAsk, trust.isTrusted(path: path, content: job.blockText) { return run() } 365 if !job.alwaysAsk, trust.isTrusted(path: path, content: job.blockText) { return run() }
@@ -461,6 +461,8 @@ struct ContentView: View {
461 let all = session.openPaths 461 let all = session.openPaths
462 if UnsavedChanges.resolve(session, paths: all) { all.forEach(session.kill) } 462 if UnsavedChanges.resolve(session, paths: all) { all.forEach(session.kill) }
463 case "app.toggle-tab-bar": showTabBar.toggle() 463 case "app.toggle-tab-bar": showTabBar.toggle()
464 case "app.babel.cancel":
465 if session.isRunningBabel { session.cancelBabel() } else { commands.show("No block is running") }
464 case "app.toggle-outline": toggleOutline() 466 case "app.toggle-outline": toggleOutline()
465 case "app.toggle-markup": showMarkup.toggle() 467 case "app.toggle-markup": showMarkup.toggle()
466 case "app.toggle-line-numbers": showLineNumbers.toggle() 468 case "app.toggle-line-numbers": showLineNumbers.toggle()
Sources/Orgstar/OrgstarApp.swift +3
@@ -296,6 +296,9 @@ struct OrgstarApp: App {
296 FindMenu(commands: commands) 296 FindMenu(commands: commands)
297 } 297 }
298 CommandGroup(after: .textEditing) { 298 CommandGroup(after: .textEditing) {
299 Button("Cancel Running Block") { commands.run("app.babel.cancel") }
300 .keyboardShortcut(".")
301 .disabled(!session.isRunningBabel)
299 Button("Search Notes") { commands.focusSearch = true } 302 Button("Search Notes") { commands.focusSearch = true }
300 .keyboardShortcut("f", modifiers: [.command, .shift]) 303 .keyboardShortcut("f", modifiers: [.command, .shift])
301 } 304 }
Tests/OrgAppTests/AppTests.swift +38
@@ -550,6 +550,44 @@ struct BabelSessionTests {
550 } 550 }
551} 551}
552 552
553@MainActor
554struct BabelErrorTests {
555 func job(_ session: DocumentSession, _ path: String) throws -> BabelJob {
556 guard case .external(.babel(let job)) = session.perform(ExecuteSrcBlock(), path: path, at: 3, expect: nil) else {
557 throw CancellationError()
558 }
559 return job
560 }
561
562 /// Like `org-babel-eval`: the results go in, and the error output and status are shown.
563 @Test func reportsErrorsAndStillInsertsResults() async throws {
564 let notes = try Folder()
565 let file = try notes.write("e.org", "#+begin_src sh\necho out\necho oops >&2\nexit 3\n#+end_src\n")
566 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
567 session.open(file.path)
568 #expect(await session.runBabel(try job(session, file.path)) == "Exited with status 3: oops")
569 #expect(session.document?.text.hasSuffix("#+RESULTS:\n: out\n") == true)
570 }
571
572 @Test func cancelsARunningBlock() async throws {
573 let notes = try Folder()
574 let file = try notes.write("s.org", "#+begin_src sh\nsleep 20\necho late\n#+end_src\n")
575 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
576 session.open(file.path)
577 let start = ContinuousClock.now
578 var message: String?
579 session.startBabel(try job(session, file.path)) { message = $0 }
580 #expect(session.isRunningBabel)
581 try await Task.sleep(for: .milliseconds(200))
582 session.cancelBabel()
583 while message == nil, ContinuousClock.now - start < .seconds(5) { try await Task.sleep(for: .milliseconds(20)) }
584 #expect(message == "Code block canceled.")
585 #expect(!session.isRunningBabel)
586 #expect(ContinuousClock.now - start < .seconds(5))
587 #expect(session.document?.text.contains("#+RESULTS:") == false)
588 }
589}
590
553struct EmacsExportTests { 591struct EmacsExportTests {
554 @Test(.enabled(if: EmacsRunner.executable() != nil && ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) 592 @Test(.enabled(if: EmacsRunner.executable() != nil && ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
555 func exportsBesideTheFile() async throws { 593 func exportsBesideTheFile() async throws {