import gleam/erlang/atom import gleam/erlang/process import gleam/list import gleam/otp/actor import gleam/otp/supervision import gleam/result import mala import simplifile // --- Public API --- /// A reference to the process file manager. This can be used to register /// ownership of file paths using the `register` function. /// pub opaque type ProcessFileManager { ProcessFileManager( table: mala.BagTable(process.Pid, String), subject: process.Subject(Message), ) } /// Create a new process file manager. Typically you should use the /// `supervised` function instead of this one, adding it to your application's /// supervision tree. /// pub fn start() -> actor.StartResult(ProcessFileManager) { actor.new_with_initialiser(1000, initialise) |> actor.on_message(handle_message) |> actor.start } /// A supervision specification for a new process file manager. /// pub fn supervised() -> supervision.ChildSpecification(ProcessFileManager) { supervision.worker(start) } /// Register a file system path as being owned by the process that calls this /// function, so when the process exits the process file manager will delete /// any file or directory at the path. If there is no file at that location /// then nothing happens. /// /// If the file could not be deleted (such as due to file permissions not /// allowing it) then the error is logged. /// /// The first time a process calls this function they will be registered by the /// manager process, but subsequent calls work directly with ETS and do not /// produce any work for the manager. /// pub fn register( manager: ProcessFileManager, ownership_of new_path: String, ) -> Result(Nil, Nil) { let owner = process.self() let table = manager.table use already_registered <- result.try(mala.has_key(table, owner)) case already_registered { // If this process is already registered then all that needs to be done is // to insert the new file path into the table so it will be deleted later. True -> mala.insert(table, owner, new_path) // If this is a new process then it has to ask the manager process to // register it. False -> Ok(actor.call(manager.subject, 1000, Registration(new_path, _))) } } // --- Actor implementation --- pub opaque type Message { /// A new process is registering itself as an owner of files. Registration(path: String, reply: process.Subject(Nil)) /// A file-owning process has exited, meaning any files it owns now need to /// be deleted to clean-up. MonitoredProcessDown(pid: process.Pid) } type State { State(table: mala.BagTable(process.Pid, String)) } fn handle_message( state: State, message: Message, ) -> actor.Next(State, Message) { case message { MonitoredProcessDown(pid:) -> handle_monitored_process_down(state, pid) Registration(path:, reply:) -> handle_registration(state, path, reply) } } fn handle_registration( state: State, path: String, reply: process.Subject(Nil), ) -> actor.Next(State, Message) { // Monitor the process, so we get informed later when the process exits. let assert Ok(pid) = process.subject_owner(reply) as "registee must be alive" let _monitor = process.monitor(pid) // Record this initial file in the table, both so it can be cleaned up and so // the code can tell that this process is already registered without going // via the manager actor again. let assert Ok(_) = mala.insert(state.table, pid, path) as "ETS table exists" // Inform the registee their registration is complete process.send(reply, Nil) // Onwards! actor.continue(state) } fn handle_monitored_process_down( state: State, pid: process.Pid, ) -> actor.Next(State, Message) { let table = state.table // Find and un-register all the files owned by this process let assert Ok(paths) = mala.get(table, pid) as "ETS table exists" let assert Ok(_) = mala.delete_key(table, pid) as "ETS table exists" // Delete them all. list.each(paths, delete_file) // All done, let's continue. actor.continue(state) } fn delete_file(path: String) -> Nil { case simplifile.delete(path) { // File deleted successfully Ok(Nil) -> Nil // There was no file to delete Error(simplifile.Enoent) -> Nil // The file could not be deleted Error(error) -> { log_error([ #(atom.create("message"), "process_file failed to delete file"), #(atom.create("cause"), simplifile.describe_error(error)), #(atom.create("path"), path), ]) Nil } } } type DoNotLeak @external(erlang, "logger", "error") fn log_error(report: List(#(atom.Atom, String))) -> DoNotLeak fn initialise( subject: process.Subject(Message), ) -> Result(actor.Initialised(State, Message, ProcessFileManager), b) { let table = mala.new() let handle = ProcessFileManager(table:, subject:) let selector = process.new_selector() |> process.select(subject) |> process.select_monitors(convert_down_message) actor.initialised(State(table)) |> actor.selecting(selector) |> actor.returning(handle) |> Ok } fn convert_down_message(down: process.Down) -> Message { let assert process.ProcessDown(pid:, ..) = down as "process_file manager actor should never be monitoring a port" MonitoredProcessDown(pid:) }