Sources/OrgApp/BabelRunner.swift
86 lines · 4275 bytes
1import Foundation
2import OrgCore
3
4/// Runs Babel jobs as processes: the interpreter reads the program on standard input, in the
5/// file's folder or `:dir`.
6public enum BabelRunner {
7 public struct Failure: Error, CustomStringConvertible {
8 public let description: String
9 }
10
11 /// PATH with the usual install locations, which an app launched from the Dock lacks.
12 static var environment: [String: String] {
13 var environment = ProcessInfo.processInfo.environment
14 let extra = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]
15 let path = (environment["PATH"] ?? "").split(separator: ":").map(String.init)
16 environment["PATH"] = (path + extra.filter { !path.contains($0) }).joined(separator: ":")
17 return environment
18 }
19
20 public static func run(_ job: BabelJob, fileDirectory: URL, timeout: Duration = .seconds(300)) async throws -> BabelOutput {
21 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-babel-\(UUID().uuidString)")
22 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
23 defer { try? FileManager.default.removeItem(at: folder) }
24 let resultFile = folder.appendingPathComponent("result")
25 let input = job.input.replacingOccurrences(of: Babel.resultFileToken, with: resultFile.path)
26
27 let process = Process()
28 process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
29 process.environment = environment
30 let stdin = Pipe()
31 switch job.runner {
32 case .process(let executable, let arguments):
33 process.arguments = [executable] + arguments
34 case .emacs:
35 guard let emacs = EmacsRunner.executable() else { throw Failure(description: "Emacs isn't installed, so Emacs Lisp blocks can't run.") }
36 let script = folder.appendingPathComponent("babel.el")
37 try input.write(to: script, atomically: true, encoding: .utf8)
38 process.arguments = [emacs.path, "-Q", "--batch", "-l", script.path]
39 }
40 let directory = job.directory.map { URL(fileURLWithPath: $0, relativeTo: fileDirectory).standardizedFileURL } ?? fileDirectory
41 guard FileManager.default.fileExists(atPath: directory.path) else { throw Failure(description: "No folder \(directory.path) for :dir.") }
42 process.currentDirectoryURL = directory
43 let stdout = folder.appendingPathComponent("stdout")
44 FileManager.default.createFile(atPath: stdout.path, contents: nil)
45 let out = try FileHandle(forWritingTo: stdout)
46 process.standardInput = stdin
47 process.standardOutput = out
48 process.standardError = FileHandle.nullDevice
49 do {
50 try process.run()
51 } catch {
52 throw Failure(description: "Can't start \(process.arguments?.first ?? "the interpreter"): \(error.localizedDescription)")
53 }
54 if case .process = job.runner {
55 let handle = stdin.fileHandleForWriting
56 let data = Data(input.utf8)
57 // Large programs would fill the pipe; write off the main thread.
58 Task.detached {
59 try? handle.write(contentsOf: data)
60 try? handle.close()
61 }
62 } else {
63 try stdin.fileHandleForWriting.close()
64 }
65 let deadline = ContinuousClock.now + timeout
66 while process.isRunning {
67 if ContinuousClock.now > deadline {
68 process.terminate()
69 throw Failure(description: "The block took longer than \(timeout) and was stopped.")
70 }
71 do {
72 try await Task.sleep(for: .milliseconds(30))
73 } catch {
74 process.terminate()
75 throw CancellationError()
76 }
77 }
78 try out.close()
79 let output = (try? String(contentsOf: stdout, encoding: .utf8)) ?? ""
80 let result = try? String(contentsOf: resultFile, encoding: .utf8)
81 if case .process(let executable, _) = job.runner, process.terminationStatus == 127, output.isEmpty {
82 throw Failure(description: "\(executable) isn't installed.")
83 }
84 return BabelOutput(stdout: output, resultFile: result)
85 }
86}