This is a fun, important, fairly self-contained project that we aren't blocked on right now, and requiring minimal background. If you'd like to get involved in Unison development, read on and see if you'd like to take the lead on an implementation!
This project will be an important component of the distributed systems API. Reading or at least skimming that post is probably good background but isn't strictly necessary.
When a Unison node receives a computation to evaluate from another node (a "foreign computation"), currently we do so in in the same process as the node itself. This is bad for a few reasons:
- What if the foreign computation is an infinite loop? (Or a computation that provably terminates but which takes longer than the age of the universe to do so...)
- What if the foreign computation deliberately leaks a huge amount of memory?
- There's also the concern that the foreign computation will do something evil like delete random files on our filesystem. That level of sandboxing is handled as a separate layer. I'll touch on how this relates to this project a bit later.
Since we don't necessarily trust the foreign computation with the full set of CPU and memory resources available to a Unison node, we need to run foreign computations in some sort of sandbox. Here's the API (subject to tweaking, but this is probably a good start):
module Unison.Runtime.ProcessPool where
import Data.Bytes.Serial
import System.Process (ProcessHandle)
newtype TimeBudget = Seconds !Int
newtype SpaceBudget = Megabytes !Int
type Budget = (TimeBudget, SpaceBudget)
newtype MaxProcesses = MaxProcesses !Int
data Err = TimeExceeded | SpaceExceeded | Killed | InsufficientProcesses
type ID = Int
data Pool a b = Pool {
-- | Evaluate the thunk in a separate process, with the given budget.
-- If there is no available process for that `Budget`, create a new one,
-- unless that would exceed the `MaxProcesses` bound, in which case
-- fail fast with `Left InsufficientProcesses`.
evaluate :: Budget -> MaxProcesses -> ID -> a -> IO (Either Err b),
-- | Forcibly kill the process associated with an ID. Any prior `evaluate` for
-- that `ID` should complete with `Left Killed`.
kill :: ID -> IO (),
-- | Shutdown the entire pool. After this completes, no other processes should be running
shutdown :: IO ()
}
pool :: (Serial a, Serial b) => IO ProcessHandle -> IO (Pool a b)
pool createWorker = _todo
That is the full API. The implementation should be backed by a growable pool of processes. (If Haskell threads could specify a max heap size on startup, we could do everything in-process, but unfortunately, that isn't supported and it doesn't look like it's happening anytime soon.)
Here's a simple sketch of an implementation:
- When the pool is created, launch a local (in-process) thread. Conceptually, this keeps a couple pieces of state:
available :: Map (TimeBudget, SpaceBudget) [ProcessHandle]
, which is the list of free worker processes ("workers") associated with each budget. We don't literally want to spin up a new OS process every time evaluate gets called.
running :: Map (TimeBudget, SpaceBudget) [ProcessHandle]
, which is the list of processes that are currently running a call to evaluate
.
ids :: Map ID [ProcessHandle]
, storing the mapping from ID to processes with that ID.
- When
evaluate
gets called, serialize the thunk using the argument passed to pool
. Lookup in available
to see if there's an existing process configured with that budget, and which happens to be free:
- If there isn't, check that creating a new process wouldn't exceed the maximum number of processes. If it would, fail fast with a
Left InsufficientProcesses
. If not, spin up a new process with that budget, add it to the available
map and move to the next step.
- If so, using inter-process communication (or some socket abstraction that uses IPC if on the same machine), send the serialized thunk to that process and wait for the reply, which should be deserialized as a
IO (Either Err b)
.
- When results come back, we should update the
running
, active
, and ids
state accordingly.
Note: Any restriction of privileges other than time / space budgeting will be handled before a call to evaluate
. So for instance, if we want to disallow write access to the node's local data store, this would be implemented by inspecting the term, and making sure it cannot reference any such functions. We'll call this a "capability failure" vs a "resource budget failure" caused by a computation exceeding its time or space budget.
The pool is backed by a number of worker processes (or just "workers"). A worker process will be initialized with a CPU and space budget (probably via command line flags), and its main logic will be some a -> IO b
:
module Unison.Runtime.Worker where
worker :: (Serial a, Serial b) -> (a -> IO b) -> IO ()
worker eval = ...
main :: IO ()
main = worker _todo
The time budget will be handled internal to the Haskell code, but the memory budget will have to be handled via an RTS flag. It looks like myprog +RTS -M1024m
will limit myprog
to run with 1024 megabytes. The time budget should be handled internally so that the same worker can be reused, rather than having to spin up a new process every time. It will be quite common to have lots of sequential requests with the same budget.
If you are interested in this project and have questions (or suggestions), please post them here, or come discuss in the chat room.