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 {
4343 let stdout = folder.appendingPathComponent("stdout")
4444 FileManager.default.createFile(atPath: stdout.path, contents: nil)
4545 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)
4649 process.standardInput = stdin
4750 process.standardOutput = out
48 process.standardError = FileHandle.nullDevice
51 process.standardError = err
4952 do {
5053 try process.run()
5154 } catch {
@@ -76,11 +79,13 @@ public enum BabelRunner {
7679 }
7780 }
7881 try out.close()
82 try err.close()
7983 let output = (try? String(contentsOf: stdout, encoding: .utf8)) ?? ""
84 let errors = (try? String(contentsOf: stderr, encoding: .utf8)) ?? ""
8085 let result = try? String(contentsOf: resultFile, encoding: .utf8)
8186 if case .process(let executable, _) = job.runner, process.terminationStatus == 127, output.isEmpty {
8287 throw Failure(description: "\(executable) isn't installed.")
8388 }
84 return BabelOutput(stdout: output, resultFile: result)
89 return BabelOutput(stdout: output, resultFile: result, stderr: errors, status: process.terminationStatus)
8590 }
8691}
Sources/OrgApp/DocumentSession.swift +30 −2
@@ -536,21 +536,49 @@ public final class DocumentSession {
536536 guard entries.contains(where: { $0 === entry }) else { return "The file was closed before the block finished." }
537537 let current = entry.buffer
538538 let document = current.document
539 let done = output.problem ?? "Code block evaluation complete."
539540 switch Babel.finish(job, output: output, text: document.text, settings: document.tree.settings) {
540541 case .failure(.message(let message)):
541542 return message
542543 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 }
544545 let result = EditResult(baseRevision: document.revision, edits: edits)
545546 if case .failed(let message) = current.perform(PrecomputedEdit(result), now: Date(), answers: [:]) { return message }
546547 changed(entry)
547 return "Code block evaluation complete."
548 return done
548549 }
550 } catch is CancellationError {
551 return "Code block canceled."
549552 } catch {
550553 return "\(error)"
551554 }
552555 }
553556
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
554582 /// A buffer changed outside typing: the current one updates its views, another saves.
555583 private func changed(_ entry: Entry) {
556584 if entry === current { bufferChanged() } else { scheduleAutosave(entry); refreshLists() }
Sources/OrgCore/Compute/Babel.swift +18 −1
@@ -42,9 +42,26 @@ public struct BabelJob: Sendable, Equatable {
4242public struct BabelOutput: Sendable, Equatable {
4343 public var stdout: String
4444 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) {
4650 self.stdout = stdout
4751 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 }
4865 }
4966}
5067
Sources/OrgEditorAppKit/EditorCommands.swift +1
@@ -48,6 +48,7 @@ public enum EditorCommands {
4848 EditorCommand(id: "app.buffer.next", title: "Next Buffer", action: .app),
4949 EditorCommand(id: "app.buffer.previous", title: "Previous Buffer", action: .app),
5050 EditorCommand(id: "app.buffer.last", title: "Last Buffer", action: .app),
51 EditorCommand(id: "app.babel.cancel", title: "Cancel Running Block", action: .app),
5152 EditorCommand(id: "app.toggle-tab-bar", title: "Show or Hide Tab Bar", action: .app),
5253 EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app),
5354 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 {
358358 }
359359 case .babel(let job):
360360 let run = {
361 commands.show("Running \(job.language) block…")
362 Task { commands.show(await session.runBabel(job)) }
361 commands.show("Running \(job.language) block… (⌘. to cancel)")
362 session.startBabel(job) { commands.show($0) }
363363 }
364364 guard let path = session.path else { return }
365365 if !job.alwaysAsk, trust.isTrusted(path: path, content: job.blockText) { return run() }
@@ -461,6 +461,8 @@ struct ContentView: View {
461461 let all = session.openPaths
462462 if UnsavedChanges.resolve(session, paths: all) { all.forEach(session.kill) }
463463 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") }
464466 case "app.toggle-outline": toggleOutline()
465467 case "app.toggle-markup": showMarkup.toggle()
466468 case "app.toggle-line-numbers": showLineNumbers.toggle()
Sources/Orgstar/OrgstarApp.swift +3
@@ -296,6 +296,9 @@ struct OrgstarApp: App {
296296 FindMenu(commands: commands)
297297 }
298298 CommandGroup(after: .textEditing) {
299 Button("Cancel Running Block") { commands.run("app.babel.cancel") }
300 .keyboardShortcut(".")
301 .disabled(!session.isRunningBabel)
299302 Button("Search Notes") { commands.focusSearch = true }
300303 .keyboardShortcut("f", modifiers: [.command, .shift])
301304 }
Tests/OrgAppTests/AppTests.swift +38
@@ -550,6 +550,44 @@ struct BabelSessionTests {
550550 }
551551}
552552
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
553591struct EmacsExportTests {
554592 @Test(.enabled(if: EmacsRunner.executable() != nil && ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
555593 func exportsBesideTheFile() async throws {