Packages

Automatically clean-up temporary files when processes exit

Current section

Files

Jump to
process_file src process_file.gleam
Raw

src/process_file.gleam

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:)
}