packages feed

phino 0.0.117 → 0.0.118

raw patch · 8 files changed

+986/−274 lines, 8 filesPVP: major bump suggested

API removals or changes: PVP suggests a major version bump

API changes (from Hackage documentation)

- Atoms: Atom :: Runtime -> Text -> Atom
- Atoms: [_runtime] :: Atom -> Runtime
- Atoms: [_script] :: Atom -> Text
- Atoms: instance Data.Aeson.Types.FromJSON.FromJSON Atoms.Answer
- Atoms: instance Data.Aeson.Types.FromJSON.FromJSON Atoms.Atom
- Atoms: type Registry = Map Text Atom
+ Atoms: Executable :: FilePath -> Program
+ Atoms: Resident :: Session -> Atom
+ Atoms: Scripted :: Runtime -> Text -> Program
+ Atoms: Transient :: Program -> Atom
+ Atoms: closeRegistry :: Registry -> IO ()
+ Atoms: data Program
+ Atoms: data Registry
+ Atoms: data Session
+ Atoms: instance Data.Aeson.Types.FromJSON.FromJSON Atoms.Entry
+ Atoms: instance Data.Aeson.Types.FromJSON.FromJSON Atoms.Program
+ Atoms: instance Data.Aeson.Types.FromJSON.FromJSON Atoms.Reply
+ Atoms: instance GHC.Classes.Eq Atoms.Program
+ Atoms: instance GHC.Classes.Eq Atoms.Session
+ Atoms: instance GHC.Classes.Ord Atoms.Program
+ Atoms: instance GHC.Classes.Ord Atoms.Runtime
+ Atoms: instance GHC.Show.Show Atoms.Program
+ Atoms: instance GHC.Show.Show Atoms.Session

Files

README.md view
@@ -104,72 +104,128 @@  Which λ functions exist is a property of the object model being dataized, not of the calculus, so `phino` implements none of them. They come from a JSON-registry given with `--atoms`, keyed by λ name:+registry given with `--atoms`, keyed by regular expressions over λ names:  ```json {   "L_number_plus": {     "rt": "node",-    "script": "const fs = require('fs'); ..."+    "script": "const readline = require('readline'); ..."   } } ``` -The `rt` field names the executable the `script` is run under. Only `node` is-supported for now; a registry naming any other runtime is refused when the file-is read, before dataization starts.+The `rt` field names the interpreter the `script` is run under. Only `node` is+supported for now; a registry naming any other interpreter is refused when the+file is read, before dataization starts.  When 𝔼 reaches a λ function the registry carries, `phino` writes its `script`-to a temporary file and runs it as a POSIX process under that interpreter, with-the λ name as the first command-line argument:+to a temporary file and runs it as a POSIX process under that interpreter:  ```text-node /tmp/phino-atom-4f2a.js L_number_plus+node /tmp/phino-atom-4f2a.js ``` -The name matters: one script may be registered under several λ names and branch-on it, which is where `node` puts it — `process.argv[2]`. The script is then-fed one JSON object on `stdin`:+An atom that is already a program needs no interpreter and no staging. Such an+entry says `exec` and gives a `path` instead of a `script`:  ```json {-  "b": "⟦ x ↦ Φ.number( as-bytes ↦ … ), ρ ↦ ⟦ … ⟧ ⟧",-  "s": "⟦ bytes ↦ ⟦ … ⟧, number ↦ ⟦ … ⟧, φ ↦ … ⟧"+  "L_number_plus": {+    "rt": "exec",+    "path": "/opt/eo/atoms/number-plus"+  } } ``` -Here `b` is the formation being evaluated, with its λ binding removed so that-the script may dispatch on it, and `s` is the universe Φ. Both are canonical-𝜑-calculus on a single line — no syntax sugar, whatever `--sweet` says about-the output of the run — so a script never has to know about `phino`'s sugar in-order to find a datum: every byte array is spelled out as a Δ binding.+`phino` spawns that file directly, as the executable binary it is, with no+arguments. A `path` that names no file, or a file nobody may run, is refused+where the registry is read, together with the unknown runtimes. -The script writes one JSON object to `stdout`:+Whichever way it is run, the program is talked to over `stdin` and `stdout`,+one JSON object per line, in the letters of the evaluation rule of the+[𝜑-calculus paper](https://github.com/objectionary/calculus-paper),+𝔼(𝑏, 𝑒, 𝑠) = 𝑛, where 𝑏 is the formation, 𝑒 the universe and 𝑛 the normal+form the atom answers with: -```json-{ "n": "11" }+```text+{"𝑒": "⟦ bytes ↦ ⟦ … ⟧, number ↦ ⟦ … ⟧, φ ↦ … ⟧"}+{"id": 1, "λ": "L_number_plus", "𝑏": "⟦ x ↦ Φ.number( … ), ρ ↦ ⟦ … ⟧ ⟧"}+{"id": 1, "𝑛": "11"} ``` -The `n` field is the 𝜑-expression the atom answers with, in any syntax-`phino`'s parser reads — syntax sugar included, so the `11` above and the-`Φ.number( … )` it stands for are the same answer. `phino` parses it back-and hands it to 𝔼 as the atom's raw result, normalizing it exactly as it-normalizes anything else, so `--evaluations`, `--partial` and `--max-steps`-keep working unchanged. A non-zero exit, output that is not JSON, a missing-`n` or an `n` that does not parse fails the run, with the script's own-`stderr` in the message.+The first two lines are `phino`'s, the third is the program's. The universe Φ+goes under `𝑒`, in a line of its own, before the first request. Then comes the+request: an `id`, the λ name under `λ` — one program may be registered under+several names and branch on it — and, under `𝑏`, the formation being+evaluated, with its λ binding removed. Both payloads are canonical 𝜑-calculus+on a single line — no syntax sugar, whatever `--sweet` says about the output of+the run — so a program never has to know about `phino`'s sugar in order to find+a datum: every byte array is spelled out as a Δ binding. -A λ name the registry does not carry has no λ function at all, so 𝔼 gets stuck-on it. Without `--atoms` the registry is empty and every atom gets stuck.+The program answers with one line carrying the same `id` and, under `𝑛`, the+𝜑-expression the atom answers with, in any syntax `phino`'s parser reads —+syntax sugar included, so the `11` above and the `Φ.number( … )` it stands for+are the same answer. `phino` parses it back and hands it to 𝔼 as the atom's+raw result, normalizing it exactly as it normalizes anything else, so+`--evaluations`, `--partial` and `--max-steps` keep working unchanged. +A program started for the fire is asked one request, always `id` 1, and its+`stdin` is closed behind it, so it may read its input whole or line by line, as+it pleases. It is waited for once it has answered, and a non-zero exit fails+the run. So does a reply that is not JSON, carries no `𝑛`, answers another+`id`, or an `𝑛` that does not parse, or a program that quits without+answering — always with the program's own `stderr` in the message.++Each key of the registry is a regular expression, and it must match the whole+λ name, so a plain name such as `L_number_plus` means that one atom and nothing+else, while `L_number_.*` stands for every atom of `number`. When 𝔼 reaches a+λ function, the keys are tried top to bottom, in the order the file lists them,+and the first one that matches is the entry fired, so a key placed above+another hides whatever the two have in common. A key that is not a regular+expression is refused where the registry is read.++A λ name no key matches has no λ function at all, so 𝔼 gets stuck on it.+Without `--atoms` the registry is empty and every atom gets stuck.++One process per fire is where a program that is slow to start — a JVM, say —+spends most of the run. An entry saying `serve` has `phino` start its program+once, on the first fire, and keep it for the rest of the run, whether it is a+`script` or a `path`. Together with a key that matches many names, this is how+one program stands for a whole object model without being spelled once per+atom:++```json+{+  "L_bytes_eq": {+    "rt": "node",+    "script": "const readline = require('readline'); ..."+  },+  ".*": {+    "rt": "exec",+    "path": "/opt/eo/atoms/resident",+    "serve": true+  }+}+```++Every λ name registered on the same program, under one key or under several,+is served by the same process, so there is one of it, however many atoms it+stands for. The lines are the same:+the program reads request after request off its `stdin`, each with the next+`id`, and answers each in turn. The universe is told again only when a fire+comes with a different one; the program keeps the last one it was told. When+the run is over, whatever it ended with, `phino` closes the program's `stdin`,+which is its cue to quit, and terminates it if it has not quit within a second.+ ### Reducing the operands of an atom -A script gets at the parts of `b` by calling `phino` again, so no API has to be-exposed for it. The `--inside` option is how it asks: the expression it names-is bound to a fresh synthetic attribute of the input expression, which the run-takes as the universe, normalized there, and then dataized. This is the same-trick `phino` plays internally whenever it has to reduce a sub-expression the-program does not contain:+A program gets at the parts of `𝑏` by calling `phino` again, so no API has to+be exposed for it. The `--inside` option is how it asks: the expression it+names is bound to a fresh synthetic attribute of the input expression, which+the run takes as the universe, normalized there, and then dataized. This is the+same trick `phino` plays internally whenever it has to reduce a sub-expression+the program does not contain:  ```bash $ phino dataize --atoms=atoms.json --inside='5.plus( 6 )' universe.phi@@ -177,33 +233,49 @@ ```  Here `universe.phi` is the 𝜑-program the atom is being fired inside — the very-text the script was handed as `s`, which it feeds back on `stdin`.+text the program was told under `𝑒`, which it feeds back on `stdin`.  So a `L_number_plus` that reduces its own operands reads like this:  ```js-const fs = require('fs');+const readline = require('readline'); const { execFileSync } = require('child_process');-const atom = process.argv[2];-if (atom !== 'L_number_plus') {-  throw new Error(`unsupported atom ${atom}`);-}-const { b, s } = JSON.parse(fs.readFileSync(0, 'utf8'));+let universe; const dataized = (expr) => execFileSync(   'phino',   ['dataize', '--atoms=atoms.json', `--inside=${expr}`],-  { input: s, encoding: 'utf8' }+  { input: universe, encoding: 'utf8' } ).trim();-const number = (expr) => Buffer.from(dataized(expr).replace(/-/g, ''), 'hex').readDoubleBE(0);-const sum = Buffer.alloc(8);-sum.writeDoubleBE(number(`${b}.ρ`) + number(`${b}.x`));-const hex = [...sum]-  .map((octet) => octet.toString(16).toUpperCase().padStart(2, '0'))-  .join('-');-process.stdout.write(JSON.stringify({-  n: `Φ.number( as-bytes ↦ Φ.bytes( data ↦ ⟦ Δ ⤍ ${hex} ⟧ ) )`-}));+const number = (expr) => Buffer+  .from(dataized(expr).replace(/-/g, ''), 'hex')+  .readDoubleBE(0);+const hex = (value) => {+  const bytes = Buffer.alloc(8);+  bytes.writeDoubleBE(value);+  return [...bytes]+    .map((octet) => octet.toString(16).toUpperCase().padStart(2, '0'))+    .join('-');+};+readline.createInterface({ input: process.stdin }).on('line', (line) => {+  const message = JSON.parse(line);+  if ('𝑒' in message) {+    universe = message['𝑒'];+    return;+  }+  if (message['λ'] !== 'L_number_plus') {+    throw new Error(`unsupported atom ${message['λ']}`);+  }+  const b = message['𝑏'];+  const sum = hex(number(`${b}.ρ`) + number(`${b}.x`));+  process.stdout.write(`${JSON.stringify({+    id: message.id,+    '𝑛': `Φ.number( as-bytes ↦ Φ.bytes( data ↦ ⟦ Δ ⤍ ${sum} ⟧ ) )`,+  })}\n`);+}); ```++Written this way, reading until its `stdin` closes, the same program runs once+per fire and serves the whole run alike; only the registry entry decides.  The `--inside` option cannot be combined with `--locator`, since it aims the run at the binding it mints itself. Both `dataize` and `morph` take `--atoms`
phino.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: phino-version: 0.0.117+version: 0.0.118 license: MIT synopsis: Command-Line Manipulator of 𝜑-Calculus Expressions description: Please see the README on GitHub at <https://github.com/objectionary/phino#readme>
src/Atoms.hs view
@@ -9,24 +9,44 @@ -- Which λ functions exist is a property of the object model being dataized, -- not of the calculus. phino therefore implements none of them: it reads a -- registry of them from a JSON file given with '--atoms' and fires each one as--- a POSIX process. The registry maps a λ name to the runtime that runs it and--- the script it runs:+-- a POSIX process. Each key of the registry is a regular expression over λ+-- names, tried top to bottom, and the first one matching the whole name of the+-- atom being fired wins; its entry names the runtime that runs a script, or+-- 'exec' and the path of a file that runs on its own, and says with 'serve'+-- whether the program is to be started once and kept for the run: -- -- > { -- >   "L_bytes_eq": { -- >     "rt": "node",--- >     "script": "const fs = require('fs'); ..."+-- >     "script": "const readline = require('readline'); ..."+-- >   },+-- >   "L_number_plus": {+-- >     "rt": "exec",+-- >     "path": "/opt/eo/atoms/number-plus"+-- >   },+-- >   ".*": {+-- >     "rt": "exec",+-- >     "path": "/opt/eo/atoms/resident",+-- >     "serve": true -- >   } -- > } ----- A name absent from the registry has no λ function at all: 𝔼 gets stuck on--- it, exactly as it does for a name no one ever declared (see 'Stuck' in--- 'Dataize').+-- Whichever way it is run, a program speaks one protocol, in the letters of the+-- evaluation rule of the calculus paper, 𝔼(𝑏, 𝑒, 𝑠) = 𝑛: one JSON object per+-- line, the universe under '𝑒', then a request with an 'id', the λ name under+-- 'λ' and the formation under '𝑏', answered by a line with the same 'id' and+-- the 𝜑-expression under '𝑛'.+--+-- A name no key matches has no λ function at all: 𝔼 gets stuck on it, exactly+-- as it does for a name no one ever declared (see 'Stuck' in 'Dataize'). module Atoms   ( Atom (..)   , AtomException (..)+  , Program (..)   , Registry   , Runtime (..)+  , Session (_program)+  , closeRegistry   , emptyRegistry   , fireAtom   , readRegistry@@ -36,11 +56,18 @@ where  import AST-import Control.Exception (Exception, bracket, catch, throwIO)-import Control.Monad (unless)-import Data.Aeson (FromJSON (parseJSON), eitherDecodeStrict', object, withObject, withText, (.:), (.=))+import Control.Concurrent.MVar (MVar, modifyMVar, modifyMVar_, newMVar)+import Control.Exception (Exception, SomeException, catch, onException, throwIO, try)+import Control.Monad (foldM, unless)+import Data.Aeson (FromJSON (parseJSON), eitherDecodeStrict', object, withObject, withText, (.!=), (.:), (.:?), (.=)) import qualified Data.Aeson as A+import Data.Aeson.Decoding (toEitherValue)+import Data.Aeson.Decoding.ByteString (bsToTokens)+import Data.Aeson.Decoding.Tokens (TkRecord (TkPair, TkRecordEnd, TkRecordErr), Tokens (TkErr, TkRecordOpen))+import qualified Data.Aeson.Key as Key+import Data.Aeson.Types (JSONPathElement (Key), parseEither, (<?>)) import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as BC import qualified Data.ByteString.Lazy as BSL import Data.List (find, intercalate) import Data.Map.Strict (Map)@@ -54,38 +81,94 @@ import Parser (parseExpression) import Printer (printExpression') import Sugar (SugarType (SALTY))-import System.Directory (getTemporaryDirectory, removePathForcibly)+import System.Directory (doesFileExist, executable, getPermissions, getTemporaryDirectory, removePathForcibly) import System.Exit (ExitCode (ExitFailure, ExitSuccess))-import System.IO (Handle, IOMode (WriteMode), hClose, hSetBinaryMode, openBinaryTempFile, withBinaryFile)-import System.Process (CreateProcess (std_err, std_in, std_out), ProcessHandle, StdStream (CreatePipe, UseHandle), createProcess, proc, waitForProcess)+import System.IO (Handle, hClose, hFlush, hSetBinaryMode, openBinaryTempFile)+import System.Process (CreateProcess (std_err, std_in, std_out), ProcessHandle, StdStream (CreatePipe, UseHandle), createProcess, proc, terminateProcess, waitForProcess)+import System.Timeout (timeout) import Text.Printf (printf)+import Text.Regex.PCRE (matchTest)+import Text.Regex.PCRE.ByteString (Regex, compUTF8, compile, execBlank)  -- The interpreter a script is run under, named after the executable itself: -- only 'node' for now. A registry naming any other runtime is rejected when it -- is read, before dataization starts, so a run never gets half-way through a -- program to discover that one of its atoms cannot be run at all. data Runtime = RtNode+  deriving stock (Eq, Ord, Show)++-- How the program of an atom is started: as a script under the interpreter of+-- its runtime, which phino stages in a temporary file, or as an executable+-- file, which phino runs as it is, since the object model brought its own+-- binary and there is nothing to stage.+data Program+  = Scripted Runtime T.Text+  | Executable FilePath+  deriving stock (Eq, Ord, Show)++-- One λ function phino may fire: its program, either started afresh for every+-- fire and gone once it has answered, or kept in a session for the run, so+-- that one process answers every fire — which is what an entry saying 'serve'+-- asks for, and what a program that is slow to start needs.+data Atom+  = Transient Program+  | Resident Session   deriving stock (Eq, Show) --- One entry of the registry: the runtime and the source of the script.-data Atom = Atom-  { _runtime :: Runtime-  , _script :: T.Text+-- A program to be kept for the run, together with the process phino has+-- started of it, if it has: none until the first fire, since a run that never+-- reaches the atom should not pay for it. Every entry naming the same program+-- shares one session, so one process serves all the λ names it is registered+-- under.+data Session = Session+  { _program :: Program+  , _running :: MVar (Maybe Running)   }-  deriving stock (Eq, Show) --- Every λ function phino may fire, keyed by name.-type Registry = Map T.Text Atom+-- Two sessions are the same when they keep the same program, whatever their+-- processes are up to.+instance Eq Session where+  Session left _ == Session right _ = left == right +instance Show Session where+  show (Session program _) = show program++-- A program while it runs: its streams, the file its complaints go to, the+-- file its script is staged in, if it is a script, the universe it was told+-- last, so it is told again only when the universe changes, and how many+-- requests it has been asked, which numbers the next one.+data Running = Running+  { _input :: Handle+  , _output :: Handle+  , _process :: ProcessHandle+  , _complaints :: FilePath+  , _staged :: Maybe FilePath+  , _told :: Maybe Expression+  , _requests :: Int+  }++-- One entry of the registry, as the file spells it: the program and whether+-- it is to be kept for the run.+data Entry = Entry Program Bool++-- Every λ function phino may fire, in the order the registry file lists them:+-- each key of the file, a regular expression over λ names, paired with the+-- atom its entry describes. A lookup tries them top to bottom and the first+-- key matching the whole name wins, so one entry may stand for many atoms,+-- while a plain name, being a regular expression matching itself, keeps+-- meaning that one atom.+newtype Registry = Registry [(Regex, Atom)]+ data AtomException   = -- The '--atoms' file is not a JSON registry of λ functions.     BrokenRegistry FilePath String-  | -- The interpreter of a runtime is not installed, so no script of it can run.+  | -- The program of an atom cannot be run: the interpreter of its runtime is+    -- not installed, or its executable file is missing or not executable.     NoRuntime T.Text String String-  | -- The script exited with a non-zero status; the message carries its stderr.+  | -- The program exited with a non-zero status; the message carries its stderr.     AtomBroke T.Text Int String-  | -- The script exited successfully but said nothing phino can use: its stdout-    -- is not a JSON object, carries no 'n' field, or the 𝜑-expression under it+  | -- The program said nothing phino can use: its reply is not a JSON object,+    -- carries no 𝜑-expression, answers another request, or the 𝜑-expression     -- does not parse.     AtomMute T.Text String String   deriving anyclass (Exception)@@ -117,145 +200,282 @@ runtimes :: [Runtime] runtimes = [RtNode] +-- The 'rt' of an atom that is a file rather than a script: it names no+-- interpreter, because the file runs on its own.+execName :: String+execName = "exec"++-- Every name the 'rt' field of a registry entry may take. runtimeNames :: [String]-runtimeNames = map runtimeName runtimes+runtimeNames = map runtimeName runtimes ++ [execName]  instance FromJSON Runtime where   parseJSON = withText "runtime" $ \name -> case find ((== T.unpack name) . runtimeName) runtimes of     Just runtime -> pure runtime     Nothing -> fail (printf "unknown runtime '%s', expected one of: %s" (T.unpack name) (intercalate ", " runtimeNames)) -instance FromJSON Atom where-  parseJSON = withObject "atom" $ \entry -> Atom <$> entry .: "rt" <*> entry .: "script"+instance FromJSON Program where+  parseJSON = withObject "atom" $ \entry -> do+    named <- entry .: "rt"+    if named == execName+      then Executable <$> entry .: "path"+      else Scripted <$> parseJSON (A.String (T.pack named)) <*> entry .: "script" --- What the script writes to stdout: one JSON object whose 'n' field is the--- 𝜑-expression the atom answers with.-newtype Answer = Answer T.Text+-- The 'serve' field is optional and off by default: a program is started for+-- every fire unless the entry says otherwise.+instance FromJSON Entry where+  parseJSON value = Entry <$> parseJSON value <*> withObject "atom" (\entry -> entry .:? "serve" .!= False) value -instance FromJSON Answer where-  parseJSON = withObject "answer" $ \answer -> Answer <$> answer .: "n"+-- What a program writes back for one request: the 'id' of the request it+-- answers and, under '𝑛', the 𝜑-expression the atom answers with.+data Reply = Reply Int T.Text +instance FromJSON Reply where+  parseJSON = withObject "reply" $ \reply -> do+    number <- reply .: "id"+    raw <- reply .:? "𝑛"+    maybe (fail "there is no '𝑛' in it") (pure . Reply number) raw+ -- No λ function at all: every atom gets stuck. This is what a run without -- '--atoms' fires against. emptyRegistry :: Registry-emptyRegistry = Map.empty+emptyRegistry = Registry [] --- The λ function registered under this name, if any.+-- The λ function of the first key that matches the whole name, if any. registeredAtom :: Registry -> T.Text -> Maybe Atom-registeredAtom registry func = Map.lookup func registry+registeredAtom (Registry rules) func = snd <$> find (\(pattern, _) -> matchTest pattern (encodeUtf8 func)) rules --- Read the registry of λ functions from a JSON file. An unknown runtime, a--- missing 'script' or malformed JSON fails here, before any dataization--- starts.+-- Read the registry of λ functions from a JSON file. A key that is no regular+-- expression, an unknown runtime, a missing 'script', a 'path' that names no+-- executable file or malformed JSON fails here, before any dataization starts.+-- The entries that are to keep the same program are given one session between+-- them, so that one resident process answers for every key it is registered+-- under. readRegistry :: FilePath -> IO Registry readRegistry path = do   content <- BS.readFile path `catch` unreadable-  case eitherDecodeStrict' content of-    Left failure -> throwIO (BrokenRegistry path failure)-    Right registry -> do-      logDebug (printf "Loaded %d atom(s) from '%s'" (Map.size registry) path)-      pure registry+  entries <- either (throwIO . BrokenRegistry path) pure (listed content)+  mapM_ (uncurry runnable) entries+  (rules, _) <- foldM admitted ([], Map.empty) entries+  logDebug (printf "Loaded %d atom(s) from '%s'" (length rules) path)+  pure (Registry (reverse rules))   where     unreadable :: IOError -> IO BS.ByteString     unreadable failure = throwIO (BrokenRegistry path (show failure))+    -- The entries of the file in the order it lists them, which is the order+    -- the keys are tried in and which the object aeson would decode the file+    -- to forgets, so the file is walked token by token instead.+    listed :: BS.ByteString -> Either String [(T.Text, Entry)]+    listed content = case bsToTokens content of+      TkRecordOpen record -> paired record+      TkErr failure -> Left failure+      _ -> Left "the file is not a JSON object"+    paired :: TkRecord BS.ByteString String -> Either String [(T.Text, Entry)]+    paired (TkPair key tokens) = do+      (value, rest) <- toEitherValue tokens+      entry <- parseEither (\raw -> parseJSON raw <?> Key key) value+      ((Key.toText key, entry) :) <$> paired rest+    paired (TkRecordEnd rest)+      | BS.all (`BS.elem` " \t\r\n") rest = Right []+      | otherwise = Left "there is more in the file than the JSON object"+    paired (TkRecordErr failure) = Left failure+    -- The key as the regular expression it is, made to match the whole name,+    -- so that a plain name means that one atom and not every name it is a+    -- part of.+    compiled :: T.Text -> IO Regex+    compiled key = compile compUTF8 execBlank (encodeUtf8 ("^(?:" <> key <> ")$")) >>= either broken pure+      where+        broken :: (a, String) -> IO Regex+        broken (_, failure) = throwIO (BrokenRegistry path (printf "the key '%s' is not a regular expression: %s" (T.unpack key) failure))+    -- The file of an executable atom is the only thing phino knows about it,+    -- and it staged none of it, so the file is looked at here, while the+    -- registry is being read, rather than half-way through a program that+    -- turns out to name that atom.+    runnable :: T.Text -> Entry -> IO ()+    runnable func (Entry (Executable file) _) = do+      there <- doesFileExist file+      unless there (throwIO (NoRuntime func file "there is no such file"))+      allowed <- executable <$> getPermissions file+      unless allowed (throwIO (NoRuntime func file "the file is not executable"))+    runnable _ _ = pure ()+    -- Turn an entry into the atom phino fires, keyed by its pattern and, when+    -- it is to keep its program, sharing a session with the entries keeping+    -- the same one; the rules come out newest first.+    admitted :: ([(Regex, Atom)], Map Program Session) -> (T.Text, Entry) -> IO ([(Regex, Atom)], Map Program Session)+    admitted (rules, sessions) (key, Entry program serve) = do+      pattern <- compiled key+      (atom, kept) <- if serve then resident program sessions else pure (Transient program, sessions)+      pure ((pattern, atom) : rules, kept)+    resident :: Program -> Map Program Session -> IO (Atom, Map Program Session)+    resident program sessions = do+      session <- maybe (Session program <$> newMVar Nothing) pure (Map.lookup program sessions)+      pure (Resident session, Map.insert program session sessions) --- Fire the λ function 'func' by running its script as a POSIX process under--- the interpreter of its runtime, with the λ name as the first command-line--- argument — one script may be registered under several names and branch on--- it. The script is fed a JSON object on stdin (see 'payload') and answers--- with one on stdout; the 𝜑-expression under 'n' becomes the atom's raw--- result, which 𝔼 normalizes exactly as it normalized the answer of a built-in--- one. A non-zero exit, unparsable output or a missing 'n' fails the run.+-- Stop every resident program the registry has started: its stdin is closed,+-- which is its cue to quit, and a program that has not quit within a second is+-- terminated. The runners call this when the run is over, whatever it ended+-- with, so that no process outlives the phino that started it.+closeRegistry :: Registry -> IO ()+closeRegistry (Registry rules) = mapM_ (dismissed . snd) rules+  where+    dismissed :: Atom -> IO ()+    dismissed (Resident Session{..}) = modifyMVar_ _running (maybe (pure Nothing) (\running -> Nothing <$ stopped briefly running))+    dismissed _ = pure ()++-- Fire the λ function 'func' by asking its program. A transient program is+-- started for the fire and waited for once it has answered, so that its exit+-- status has its say; a resident one is started on the first fire and stays+-- for the run, kept whatever the fire ended with, so that 'closeRegistry'+-- finds it. Whichever way, the 𝜑-expression the program answers with becomes+-- the atom's raw result, which 𝔼 normalizes exactly as it normalized the+-- answer of a built-in one. fireAtom :: T.Text -> Atom -> Expression -> Expression -> IO Expression-fireAtom func Atom{..} form univ =-  withTemp (printf "phino-atom-.%s" (extension _runtime)) (encodeUtf8 _script) $ \script ->-    withTemp "phino-atom-.err" "" $ \errors -> do-      logDebug (printf "Firing atom '%s' as '%s %s %s'" (T.unpack func) (interpreter _runtime) script (T.unpack func))-      (status, answer) <- executed script errors-      complaint <- readErrors errors-      unless (null complaint) (logDebug (printf "Atom '%s' wrote to stderr: %s" (T.unpack func) complaint))-      case status of-        ExitFailure code -> throwIO (AtomBroke func code complaint)-        ExitSuccess -> answered answer+fireAtom func (Transient program) form univ = do+  running <- started func program+  (_, answer) <- asked func running form univ hClose `onException` stopped patiently running+  (status, complaint) <- stopped patiently running+  unless (null complaint) (logDebug (printf "Atom '%s' wrote to stderr: %s" (T.unpack func) complaint))+  case status of+    ExitFailure code -> throwIO (AtomBroke func code complaint)+    ExitSuccess -> pure answer+fireAtom func (Resident Session{..}) form univ = do+  outcome <- modifyMVar _running $ \current -> do+    running <- maybe (started func _program) pure current+    attempt <- try (asked func running form univ hFlush) :: IO (Either SomeException (Running, Expression))+    pure (Just (either (const running) fst attempt), snd <$> attempt)+  either throwIO pure outcome++-- Start the program, with its input and its output on pipes and its complaints+-- in a file that lives as long as the process does: a script is staged in a+-- temporary file first and handed to the interpreter of its runtime, an+-- executable file is run as it is. Every stream is bytes: a 𝜑 expression+-- carries characters no single-byte locale can spell, so nothing is left to+-- the locale.+started :: T.Text -> Program -> IO Running+started func program = do+  dir <- getTemporaryDirectory+  (complaints, handle) <- openBinaryTempFile dir "phino-atom-.err"+  (executable, arguments, staged) <- commanded dir+  logDebug (printf "Starting atom '%s' as '%s'" (T.unpack func) (unwords (executable : arguments)))+  (input, output, process) <- spawned executable arguments handle `onException` discarded complaints staged+  pure (Running input output process complaints staged Nothing 0)   where-    -- Run the interpreter with its input and its output on pipes and its-    -- complaints in a file. The input is written and closed before the output is-    -- read, so the parent never has two streams to drain at once — which would-    -- need threads to be safe — and the script's own stderr, which may be-    -- anything at all, cannot fill a pipe nobody is reading. Every stream is-    -- bytes: a 𝜑 expression carries characters no single-byte locale can spell,-    -- so nothing is left to the locale.-    executed :: FilePath -> FilePath -> IO (ExitCode, BS.ByteString)-    executed script errors =-      withBinaryFile errors WriteMode $ \stderr' -> do-        (stdin', stdout', process) <- spawned script stderr'-        hSetBinaryMode stdin' True-        hSetBinaryMode stdout' True-        -- A script that dies before reading its input leaves this write with-        -- nobody to drain it. The failure worth reporting is the one the script-        -- made, so a broken pipe is swallowed here and the exit status decides.-        BS.hPut stdin' (payload form univ) `catch` unheard-        hClose stdin' `catch` unheard-        answer <- BS.hGetContents stdout'-        status <- waitForProcess process-        pure (status, answer)-    spawned :: FilePath -> Handle -> IO (Handle, Handle, ProcessHandle)-    spawned script stderr' = do-      spawn <- createProcess started `catch` missing+    -- The command line the program is started with, and the file staged for+    -- it, if it is a script.+    commanded :: FilePath -> IO (String, [String], Maybe FilePath)+    commanded dir = case program of+      Executable file -> pure (file, [], Nothing)+      Scripted runtime script -> do+        (path, handle) <- openBinaryTempFile dir (printf "phino-atom-.%s" (extension runtime))+        BS.hPut handle (encodeUtf8 script)+        hClose handle+        pure (interpreter runtime, [path], Just path)+    spawned :: String -> [String] -> Handle -> IO (Handle, Handle, ProcessHandle)+    spawned executable arguments stderr' = do+      spawn <- createProcess (proc executable arguments){std_in = CreatePipe, std_out = CreatePipe, std_err = UseHandle stderr'} `catch` missing executable       case spawn of-        (Just stdin', Just stdout', _, process) -> pure (stdin', stdout', process)-        _ -> throwIO (AtomMute func "" "the interpreter gave phino no streams to talk over")-      where-        started :: CreateProcess-        started =-          (proc (interpreter _runtime) [script, T.unpack func])-            { std_in = CreatePipe-            , std_out = CreatePipe-            , std_err = UseHandle stderr'-            }-    missing :: IOError -> IO a-    missing failure = throwIO (NoRuntime func (interpreter _runtime) (show failure))-    unheard :: IOError -> IO ()-    unheard _ = pure ()-    -- Whatever the script complained about, decoded leniently: the stream is-    -- the script's, so it may hold anything at all.-    readErrors :: FilePath -> IO String-    readErrors errors = T.unpack . T.strip . decodeUtf8Lenient <$> BS.readFile errors-    -- Parse what the script said: a JSON object with the raw 𝜑-expression-    -- under 'n'.-    answered :: BS.ByteString -> IO Expression-    answered answer = case eitherDecodeStrict' answer of-      Left failure -> throwIO (AtomMute func spoken failure)-      Right (Answer raw) -> case parseExpression (T.unpack raw) of-        Left failure -> throwIO (AtomMute func (T.unpack raw) failure)-        Right expr -> pure expr-      where-        spoken :: String-        spoken = T.unpack (T.strip (decodeUtf8Lenient answer))+        (Just input, Just output, _, process) -> do+          hSetBinaryMode input True+          hSetBinaryMode output True+          pure (input, output, process)+        _ -> throwIO (AtomMute func "" "the program gave phino no streams to talk over")+    missing :: String -> IOError -> IO a+    missing executable failure = throwIO (NoRuntime func executable (show failure)) --- The JSON phino feeds a script on stdin: the formation being evaluated under--- 'b', with its λ binding removed so the script may dispatch on it, and the--- universe Φ under 's'. Both are rendered as canonical 𝜑-calculus on a single--- line — no syntax sugar, whatever '--sweet' says about the output of the run —--- so a script never has to know phino's sugar to find a datum: every byte array--- it may need is spelled out as a Δ binding. The text is what phino's own parser--- reads back, so a script may hand any part of it to another phino run (see the--- '--inside' option).-payload :: Expression -> Expression -> BS.ByteString-payload form univ = BSL.toStrict (A.encode (object ["b" .= rendered form, "s" .= rendered univ]))+-- Ask the running program to fire the λ function: it is told the universe,+-- unless it was told already, then the request, and its reply is read back.+-- How the request is pushed through is the caller's: a transient program has+-- its stdin closed behind it, since it may read its input whole before it+-- answers, a resident one has it flushed, since it reads on. A reply that is+-- not JSON, carries no '𝑛', answers another request, or a program that hangs+-- up fails the fire, with the program's stderr in the message.+asked :: T.Text -> Running -> Expression -> Expression -> (Handle -> IO ()) -> IO (Running, Expression)+asked func running@Running{..} form univ pushed = do+  let number = _requests + 1+      universe = lined (object ["𝑒" .= rendered univ])+      request = lined (object ["id" .= number, "λ" .= func, "𝑏" .= rendered form])+  logDebug (printf "Asking atom '%s' as request %d" (T.unpack func) number)+  said (if _told == Just univ then request else universe <> request)+  reply <- BC.hGetLine _output `catch` hungUp+  answer <- replied number reply+  pure (running{_told = Just univ, _requests = number}, answer)   where-    rendered :: Expression -> T.Text-    rendered expr = T.pack (printExpression' expr (SALTY, UNICODE, SINGLELINE, defaultMargin))+    -- A program that has died leaves the write with nobody to drain it. The+    -- failure worth reporting is the one the program made, so a broken pipe is+    -- swallowed here and the read that follows finds out.+    said :: BS.ByteString -> IO ()+    said content = (BS.hPut _input content >> pushed _input) `catch` unheard+    -- The program closed its stdout instead of answering: if it has quit with+    -- a failure, that is the failure; otherwise it went mute.+    hungUp :: IOError -> IO BS.ByteString+    hungUp _ = do+      status <- timeout 1000000 (waitForProcess _process)+      complaint <- readErrors _complaints+      case status of+        Just (ExitFailure code) -> throwIO (AtomBroke func code complaint)+        Just ExitSuccess -> throwIO (AtomMute func "" (unwords ("the program quit without answering" : [complaint | not (null complaint)])))+        Nothing -> throwIO (AtomMute func "" (unwords ("the program closed its stdout without answering" : [complaint | not (null complaint)])))+    -- Parse what the program said back: a JSON object answering this very+    -- request, with the raw 𝜑-expression under '𝑛'.+    replied :: Int -> BS.ByteString -> IO Expression+    replied number reply = case eitherDecodeStrict' reply of+      Left failure -> throwIO (AtomMute func (spoken reply) failure)+      Right (Reply echoed raw)+        | echoed /= number -> throwIO (AtomMute func (spoken reply) (printf "it answers request %d, while phino asked request %d" echoed number))+        | otherwise -> case parseExpression (T.unpack raw) of+            Left failure -> throwIO (AtomMute func (T.unpack raw) failure)+            Right expr -> pure expr --- Write the content to a fresh temporary file, hand its path to the action and--- delete the file afterwards, whatever the action does.-withTemp :: String -> BS.ByteString -> (FilePath -> IO a) -> IO a-withTemp template content action = do-  dir <- getTemporaryDirectory-  bracket (openBinaryTempFile dir template) discarded $ \(path, handle) -> do-    BS.hPut handle content-    hClose handle-    action path-  where-    discarded :: (FilePath, Handle) -> IO ()-    discarded (path, handle) = hClose handle >> removePathForcibly path+-- Hang up on the program: close its stdin, which is its cue to quit, wait for+-- it the given way and remove the files it was given, its complaints read+-- first, since they are what a failure is reported with.+stopped :: (Running -> IO ExitCode) -> Running -> IO (ExitCode, String)+stopped waited running@Running{..} = do+  hClose _input `catch` unheard+  status <- waited running+  hClose _output `catch` unheard+  complaint <- readErrors _complaints+  discarded _complaints _staged+  pure (status, complaint)++-- Wait for the program to quit for as long as it takes, draining whatever else+-- it writes, so that a chatty one never blocks on a full pipe: a transient+-- program is on its way out once it has answered, and its exit status is the+-- verdict on its answer.+patiently :: Running -> IO ExitCode+patiently Running{..} = BS.hGetContents _output >> waitForProcess _process++-- Wait for the program to quit for a second, then terminate it: a resident one+-- was told to quit and gets no say in the matter.+briefly :: Running -> IO ExitCode+briefly Running{..} = timeout 1000000 (waitForProcess _process) >>= maybe (terminateProcess _process >> waitForProcess _process) pure++-- Remove the files a program was given: the one its complaints went to and the+-- one its script was staged in, if it was a script.+discarded :: FilePath -> Maybe FilePath -> IO ()+discarded complaints staged = removePathForcibly complaints >> mapM_ removePathForcibly staged++-- Whatever the program said, decoded leniently and trimmed: the stream is the+-- program's, so it may hold anything at all.+spoken :: BS.ByteString -> String+spoken = T.unpack . T.strip . decodeUtf8Lenient++-- Whatever the program complained about, read from the file its stderr goes to.+readErrors :: FilePath -> IO String+readErrors errors = spoken <$> BS.readFile errors++unheard :: IOError -> IO ()+unheard _ = pure ()++-- One JSON object as one line, for the programs that read by the line.+lined :: A.Value -> BS.ByteString+lined value = BSL.toStrict (A.encode value) <> "\n"++-- An expression as canonical 𝜑-calculus on a single line — no syntax sugar,+-- whatever '--sweet' says about the output of the run — so a program never has+-- to know phino's sugar to find a datum: every byte array it may need is+-- spelled out as a Δ binding. The text is what phino's own parser reads back,+-- so a program may hand any part of it to another phino run (see the+-- '--inside' option).+rendered :: Expression -> T.Text+rendered expr = T.pack (printExpression' expr (SALTY, UNICODE, SINGLELINE, defaultMargin))
src/CLI/Parsers.hs view
@@ -216,7 +216,7 @@             <> metavar "FILE"             <> help               ( printf-                  "Path to the JSON registry of λ functions this run may fire, mapping each name to the runtime that runs it (%s) and the script it runs"+                  "Path to the JSON registry of λ functions this run may fire, whose keys are regular expressions over λ names, tried top to bottom, each mapped to the runtime that runs it (%s), the script or the executable it runs and, with \"serve\", whether one process of it is to serve the whole run"                   (intercalate ", " runtimeNames)               )         )
src/CLI/Runners.hs view
@@ -8,6 +8,7 @@ module CLI.Runners where  import AST+import Atoms (closeRegistry) import CLI.Helpers import CLI.Types import CLI.Validators@@ -160,10 +161,15 @@       include = (`F.include` included)   save <- saveStepFunc _stepsDir printCtx   (outcome, chain) <--    withEvalFunc _evaluations printCtx $ \record -> do-      let ctx = DataizeContext loc _maxDepth _maxCycles (Steps _maxSteps 0) _depthSensitive _shuffle _partial atoms buildTerm save record-      (universe, aiming) <- aimed _inside expr ctx-      dataize universe aiming+    withEvalFunc+      _evaluations+      printCtx+      ( \record -> do+          let ctx = DataizeContext loc _maxDepth _maxCycles (Steps _maxSteps 0) _depthSensitive _shuffle _partial atoms buildTerm save record+          (universe, aiming) <- aimed _inside expr ctx+          dataize universe aiming+      )+      `finally` closeRegistry atoms   when _sequence (printRewrittens printCtx (exclude $ include chain, False) >>= putStrLn)   unless _quiet (printOutcome printCtx outcome >>= putStrLn)   where@@ -235,10 +241,15 @@       include = (`F.include` included)   save <- saveStepFunc _stepsDir printCtx   (morphed, chain) <--    withEvalFunc _evaluations printCtx $ \record -> do-      let ctx = DataizeContext loc _maxDepth _maxCycles (Steps _maxSteps 0) _depthSensitive _shuffle _partial atoms buildTerm save record-      (universe, aiming) <- aimed _inside expr ctx-      morph universe aiming+    withEvalFunc+      _evaluations+      printCtx+      ( \record -> do+          let ctx = DataizeContext loc _maxDepth _maxCycles (Steps _maxSteps 0) _depthSensitive _shuffle _partial atoms buildTerm save record+          (universe, aiming) <- aimed _inside expr ctx+          morph universe aiming+      )+      `finally` closeRegistry atoms   when _sequence (printRewrittens printCtx (exclude $ include chain, False) >>= putStrLn)   unless _quiet (printFocused printCtx morphed >>= putStrLn)   where
test/AtomsSpec.hs view
@@ -7,54 +7,155 @@ module AtomsSpec (spec) where  import AST-import Atoms (Atom (..), Runtime (RtNode), emptyRegistry, fireAtom, readRegistry, registeredAtom)-import Control.Exception (SomeException, bracket)+import Atoms (Atom (..), Program (..), Registry, Runtime (RtNode), Session (_program), closeRegistry, emptyRegistry, fireAtom, readRegistry, registeredAtom)+import Control.Exception (SomeException, finally) import Control.Monad (forM_)+import Data.Aeson (Value, object, (.=))+import Data.Aeson.Key qualified as Key+import Data.Aeson.Types (Pair) import Data.ByteString qualified as BS import Data.List (isInfixOf) import Data.Text qualified as T import Data.Text.Encoding (encodeUtf8)-import Fixtures (withNode)+import Fixtures (resident, withExecutable, withNode, withRegistryOf, withScript, withShell, withTemp) import Parser (parseExpressionThrows)-import System.Directory (getTemporaryDirectory, removePathForcibly)-import System.IO (Handle, hClose, openBinaryTempFile)+import System.Directory (doesFileExist, getTemporaryDirectory, removePathForcibly)+import System.FilePath ((</>)) import Test.Hspec+import Text.Printf (printf) --- A registry file holding the given content, removed afterwards-withRegistry :: T.Text -> (FilePath -> IO a) -> IO a-withRegistry content action = do-  dir <- getTemporaryDirectory-  bracket (openBinaryTempFile dir "phino-registry-.json") discarded $ \(path, handle) -> do-    BS.hPut handle (encodeUtf8 content)-    hClose handle-    action path-  where-    discarded :: (FilePath, Handle) -> IO ()-    discarded (path, handle) = hClose handle >> removePathForcibly path+-- The registry of the given λ functions, every one of them the same entry+registryOf :: [T.Text] -> [Pair] -> Value+registryOf names fields = object [Key.fromText name .= object fields | name <- names] --- Fire the λ function 'L_answer' out of the given script, against a formation+-- The entry of a λ function run as the given file, which goes through JSON+-- encoding rather than into text by hand, since a Windows path spells its+-- separators with the escape character of JSON+executing :: FilePath -> [Pair]+executing file = ["rt" .= ("exec" :: T.Text), "path" .= file]++-- The entry of a λ function run as the given script under node+scripted :: T.Text -> [Pair]+scripted script = ["rt" .= ("node" :: T.Text), "script" .= script]++-- The same entry, kept for the run+served :: [Pair] -> [Pair]+served fields = ("serve" .= True) : fields++-- The text of a registry of node scripts under the given keys, in exactly the+-- order given, which 'registryOf' cannot promise+ordered :: [(T.Text, T.Text)] -> BS.ByteString+ordered entries = encodeUtf8 ("{" <> T.intercalate ", " ["\"" <> key <> "\": {\"rt\": \"node\", \"script\": \"" <> script <> "\"}" | (key, script) <- entries] <> "}")++-- The λ functions of the given registry, read from a file, with every program+-- it has started stopped afterwards, so that no spec leaves a process behind+withRegistered :: Value -> (Registry -> IO a) -> IO a+withRegistered registry action =+  withRegistryOf registry $ \path -> do+    atoms <- readRegistry path+    action atoms `finally` closeRegistry atoms++-- The λ functions of the registry naming a resident program built of the given+-- per-request snippet (see 'resident') under every given name+withServed :: [T.Text] -> T.Text -> (Registry -> IO a) -> IO a+withServed names snippet action =+  withExecutable (resident snippet) $ \file ->+    withRegistered (registryOf names (served (executing file))) action++-- Fire the given λ function out of the registry, against the same formation+-- 'fired' uses, inside the given universe+firedFrom :: Registry -> T.Text -> String -> IO Expression+firedFrom registry func universe = do+  form <- parseExpressionThrows "⟦ x ↦ ⟦ Δ ⤍ 01- ⟧ ⟧"+  univ <- parseExpressionThrows universe+  maybe (fail (printf "'%s' is not registered" (T.unpack func))) (\atom -> fireAtom func atom form univ) (registeredAtom registry func)++-- Fire the λ function 'L_answer' out of the given atom, against a formation -- binding 'x' inside a universe binding 'y'-fired :: T.Text -> IO Expression-fired script = do+fired :: Atom -> IO Expression+fired atom = do   form <- parseExpressionThrows "⟦ x ↦ ⟦ Δ ⤍ 01- ⟧ ⟧"   univ <- parseExpressionThrows "⟦ y ↦ ⟦ Δ ⤍ 02- ⟧ ⟧"-  fireAtom "L_answer" (Atom RtNode script) form univ+  fireAtom "L_answer" atom form univ --- What the script wrote under 'n' has to come back parsed, so a case asserting+-- The program a λ function is kept for the run with, if it is kept at all+kept :: Maybe Atom -> Maybe Program+kept (Just (Resident session)) = Just (_program session)+kept _ = Nothing++-- A script run once per fire, answering the request with the given JavaScript+-- expression, in which 'lines' is every line phino said, 'universe' the one+-- carrying '𝑒' and 'request' the one carrying 'id', so a case asserts on what+-- phino says rather than on how a script reads it+scripting :: T.Text -> T.Text+scripting expr =+  T.unlines+    [ "const lines = require('fs').readFileSync(0, 'utf8').split('\\n').filter(Boolean).map((line) => JSON.parse(line));"+    , "const universe = lines.find((message) => '𝑒' in message);"+    , "const request = lines.find((message) => 'id' in message);"+    , "process.stdout.write(JSON.stringify({id: request.id, '𝑛': " <> expr <> "}));"+    ]++-- A script reading phino's lines one by one until its stdin closes and+-- answering every request with how many it has seen, so a case tells one+-- process kept across fires from one started afresh for each+counting :: T.Text+counting =+  T.unlines+    [ "let seen = 0;"+    , "require('readline').createInterface({input: process.stdin}).on('line', (line) => {"+    , "  const message = JSON.parse(line);"+    , "  if ('id' in message) {"+    , "    seen += 1;"+    , "    process.stdout.write(JSON.stringify({id: message.id, '𝑛': '⟦ Δ ⤍ 0' + seen + '- ⟧'}) + '\\n');"+    , "  }"+    , "});"+    ]++-- What the script wrote under '𝑛' has to come back parsed, so a case asserting -- on it says which expression it expects in 𝜑 rather than in constructors answers :: T.Text -> String -> Expectation answers script expected = withNode $ do-  answer <- fired script+  answer <- fired (Transient (Scripted RtNode script))   wanted <- parseExpressionThrows expected   answer `shouldBe` wanted --- A firing that has to fail, with the reason naming the given fragments+-- The same, for an atom phino runs off its path instead of staging it+executes :: T.Text -> String -> Expectation+executes script expected = withShell $+  withExecutable script $ \file -> do+    answer <- fired (Transient (Executable file))+    wanted <- parseExpressionThrows expected+    answer `shouldBe` wanted++-- The same, for an atom served by a resident program built of the given+-- per-request snippet+serves :: T.Text -> String -> Expectation+serves snippet expected = withShell $+  withServed ["L_answer"] snippet $ \registry -> do+    answer <- firedFrom registry "L_answer" "⟦ y ↦ ⟦ Δ ⤍ 02- ⟧ ⟧"+    wanted <- parseExpressionThrows expected+    answer `shouldBe` wanted++-- A firing of a script that has to fail, with the reason naming the given+-- fragments fails :: T.Text -> [String] -> Expectation fails script fragments =   withNode $-    fired script+    fired (Transient (Scripted RtNode script))       `shouldThrow` (\failure -> all (`isInfixOf` show (failure :: SomeException)) fragments) +-- The same, for a served atom+refuses :: T.Text -> [String] -> Expectation+refuses snippet fragments = withShell $+  withServed ["L_answer"] snippet $ \registry ->+    firedFrom registry "L_answer" "⟦ y ↦ ⟦ Δ ⤍ 02- ⟧ ⟧"+      `shouldThrow` (\failure -> all (`isInfixOf` show (failure :: SomeException)) fragments)++-- The reply of a resident program answering the request with the given bytes+replying :: T.Text -> T.Text+replying bytes = "printf '{\"id\": %s, \"𝑛\": \"⟦ Δ ⤍ %s ⟧\"}\\n' \"$id\" \"" <> bytes <> "\""+ spec :: Spec spec = do   -- phino implements no λ function, so an empty registry is what a run without@@ -65,90 +166,314 @@    describe "readRegistry" $ do     it "reads a λ function together with its runtime and script" $-      withRegistry "{\"L_answer\": {\"rt\": \"node\", \"script\": \"say(1)\"}}" $ \path -> do+      withRegistryOf (registryOf ["L_answer"] (scripted "say(1)")) $ \path -> do         registry <- readRegistry path-        registeredAtom registry "L_answer" `shouldBe` Just (Atom RtNode "say(1)")+        registeredAtom registry "L_answer" `shouldBe` Just (Transient (Scripted RtNode "say(1)")) +    -- An atom the object model brought as a binary of its own names no+    -- interpreter at all, only the file phino is to run+    it "reads an executable λ function as the file it runs" $+      withShell $+        withExecutable "" $ \file ->+          withRegistryOf (registryOf ["L_answer"] (executing file)) $ \path -> do+            registry <- readRegistry path+            registeredAtom registry "L_answer" `shouldBe` Just (Transient (Executable file))++    -- Whether a program is kept for the run is its own flag, so any program+    -- may be kept, whatever runs it+    it "keeps an executable λ function for the run when its entry says serve" $+      withShell $+        withExecutable "" $ \file ->+          withRegistryOf (registryOf ["L_answer"] (served (executing file))) $ \path -> do+            registry <- readRegistry path+            kept (registeredAtom registry "L_answer") `shouldBe` Just (Executable file)++    it "keeps a script for the run when its entry says serve" $+      withRegistryOf (registryOf ["L_answer"] (served (scripted "say(1)"))) $ \path -> do+        registry <- readRegistry path+        kept (registeredAtom registry "L_answer") `shouldBe` Just (Scripted RtNode "say(1)")++    it "starts a program afresh for every fire when its entry says not to serve" $+      withRegistryOf (registryOf ["L_answer"] (("serve" .= False) : scripted "say(1)")) $ \path -> do+        registry <- readRegistry path+        registeredAtom registry "L_answer" `shouldBe` Just (Transient (Scripted RtNode "say(1)"))+     it "leaves a name the file does not carry unregistered" $-      withRegistry "{\"L_answer\": {\"rt\": \"node\", \"script\": \"say(1)\"}}" $ \path -> do+      withRegistryOf (registryOf ["L_answer"] (scripted "say(1)")) $ \path -> do         registry <- readRegistry path         registeredAtom registry "L_bytes_eq" `shouldBe` Nothing -    -- An unknown runtime is refused where the file is read, which is before any+    -- A key is a regular expression, so one entry may stand for a whole family+    -- of atoms and the same program need not be spelled once per name+    it "matches a λ name against the key as a regular expression" $+      withRegistryOf (registryOf ["L_number_.*"] (scripted "say(1)")) $ \path -> do+        registry <- readRegistry path+        registeredAtom registry "L_number_plus" `shouldBe` Just (Transient (Scripted RtNode "say(1)"))++    -- A plain name is a regular expression too, and it means that one atom,+    -- not every atom whose name it is a part of+    it "matches the key against the whole λ name" $+      withRegistryOf (registryOf ["L_number"] (scripted "say(1)")) $ \path -> do+        registry <- readRegistry path+        registeredAtom registry "L_number_plus" `shouldBe` Nothing++    -- The keys are tried in the order the file lists them, so a catch-all+    -- placed first hides everything below it, and the file is written by hand+    -- here because 'object' does not keep the order of its keys+    it "fires the first key top to bottom that matches" $+      withTemp "phino-atoms-.json" (ordered [(".*", "say(1)"), ("L_answer", "say(2)")]) $ \path -> do+        registry <- readRegistry path+        registeredAtom registry "L_answer" `shouldBe` Just (Transient (Scripted RtNode "say(1)"))++    it "reaches a later key when the earlier ones do not match" $+      withTemp "phino-atoms-.json" (ordered [("L_other", "say(1)"), (".*", "say(2)")]) $ \path -> do+        registry <- readRegistry path+        registeredAtom registry "L_answer" `shouldBe` Just (Transient (Scripted RtNode "say(2)"))++    -- A malformed entry is refused where the file is read, which is before any     -- dataization starts, rather than at the moment an atom of it would fire     forM_       [         ( "the runtime is not one phino can run"-        , "{\"L_answer\": {\"rt\": \"ruby\", \"script\": \"say(1)\"}}"+        , registryOf ["L_answer"] ["rt" .= ("ruby" :: T.Text), "script" .= ("say(1)" :: T.Text)]         , ["unknown runtime 'ruby'", "node"]         )       ,         ( "an entry carries no script"-        , "{\"L_answer\": {\"rt\": \"node\"}}"+        , registryOf ["L_answer"] ["rt" .= ("node" :: T.Text)]         , ["script"]         )       ,         ( "an entry carries no runtime"-        , "{\"L_answer\": {\"script\": \"say(1)\"}}"+        , registryOf ["L_answer"] ["script" .= ("say(1)" :: T.Text)]         , ["rt"]         )       ,-        ( "the file is not JSON at all"-        , "L_answer: js"-        , ["cannot be read"]+        ( "an executable entry carries no path"+        , registryOf ["L_answer"] ["rt" .= ("exec" :: T.Text)]+        , ["path"]         )+      ,+        ( "the executable file is not there"+        , registryOf ["L_answer"] (executing "no-such-atom")+        , ["L_answer", "no-such-atom", "there is no such file"]+        )+      ,+        ( "the file to serve from is not there"+        , registryOf ["L_answer"] (served (executing "no-such-atom"))+        , ["L_answer", "no-such-atom", "there is no such file"]+        )+      ,+        ( "serve is not a boolean"+        , registryOf ["L_answer"] (("serve" .= ("yes" :: T.Text)) : scripted "say(1)")+        , ["serve", "Bool"]+        )       ]-      ( \(desc, content, fragments) ->+      ( \(desc, registry, fragments) ->           it ("fails when " ++ desc) $-            withRegistry content $ \path ->+            withRegistryOf registry $ \path ->               readRegistry path                 `shouldThrow` (\failure -> all (`isInfixOf` show (failure :: SomeException)) fragments)       ) +    it "fails when the file is not JSON at all" $+      withTemp "phino-atoms-.json" "L_answer: js" $ \path ->+        readRegistry path+          `shouldThrow` (\failure -> "cannot be read" `isInfixOf` show (failure :: SomeException))++    it "fails when a key is not a regular expression" $+      withRegistryOf (registryOf ["L_(answer"] (scripted "say(1)")) $ \path ->+        readRegistry path+          `shouldThrow` (\failure -> all (`isInfixOf` show (failure :: SomeException)) ["L_(answer", "regular expression"])++    it "fails when the file is a JSON array" $+      withTemp "phino-atoms-.json" "[]" $ \path ->+        readRegistry path+          `shouldThrow` (\failure -> all (`isInfixOf` show (failure :: SomeException)) ["cannot be read", "object"])++    it "fails when there is more in the file than the JSON object" $+      withTemp "phino-atoms-.json" "{} {}" $ \path ->+        readRegistry path+          `shouldThrow` (\failure -> all (`isInfixOf` show (failure :: SomeException)) ["cannot be read", "more in the file"])+     it "fails when the file is not there" $       readRegistry "no-such-registry.json"         `shouldThrow` (\failure -> "cannot be read" `isInfixOf` show (failure :: SomeException)) +    -- A file nobody may run is refused where the registry is read, not where+    -- the atom would fire+    it "fails when the file of an executable λ function cannot be run" $+      withScript "" $ \file ->+        withRegistryOf (registryOf ["L_answer"] (executing file)) $ \path ->+          readRegistry path+            `shouldThrow` (\failure -> "not executable" `isInfixOf` show (failure :: SomeException))++    it "fails when the file to serve from cannot be run" $+      withScript "" $ \file ->+        withRegistryOf (registryOf ["L_answer"] (served (executing file))) $ \path ->+          readRegistry path+            `shouldThrow` (\failure -> "not executable" `isInfixOf` show (failure :: SomeException))+   describe "fireAtom" $ do-    it "hands back the 𝜑-expression the script wrote under 'n'" $-      answers "process.stdout.write(JSON.stringify({n: '⟦ Δ ⤍ 2A- ⟧'}))" "⟦ Δ ⤍ 2A- ⟧"+    -- Every program is spoken to in the letters of the evaluation rule of the+    -- calculus, 𝔼(𝑏, 𝑒, 𝑠) = 𝑛, one JSON object per line, whether it is+    -- started for the fire or kept for the run+    it "hands back the 𝜑-expression the script wrote under '𝑛'" $+      answers (scripting "'⟦ Δ ⤍ 2A- ⟧'") "⟦ Δ ⤍ 2A- ⟧" -    -- One script may stand for several λ functions, so the name of the one-    -- being fired is its first command-line argument — where node puts it-    it "names the λ function being fired as the first command-line argument" $+    -- One script may stand for several λ functions, so every request names+    -- the one being fired+    it "names the λ function being fired under 'λ' in the request" $       answers-        "process.stdout.write(JSON.stringify({n: process.argv[2] === 'L_answer' ? '⟦ Δ ⤍ FF- ⟧' : '⟦ Δ ⤍ 00- ⟧'}))"+        (scripting "request['λ'] === 'L_answer' ? '⟦ Δ ⤍ FF- ⟧' : '⟦ Δ ⤍ 00- ⟧'")         "⟦ Δ ⤍ FF- ⟧" -    -- The formation being evaluated arrives under 'b' and the universe Φ under-    -- 's', both as 𝜑 text on stdin-    it "feeds the formation and the universe to the script on stdin" $+    it "carries the formation under '𝑏' in the request and the universe under '𝑒'" $       answers-        "const {b, s} = JSON.parse(require('fs').readFileSync(0, 'utf8'));\-        \process.stdout.write(JSON.stringify({n: b.includes('x ↦') && s.includes('y ↦') ? '⟦ Δ ⤍ FF- ⟧' : '⟦ Δ ⤍ 00- ⟧'}))"+        (scripting "request['𝑏'].includes('x ↦') && universe['𝑒'].includes('y ↦') ? '⟦ Δ ⤍ FF- ⟧' : '⟦ Δ ⤍ 00- ⟧'")         "⟦ Δ ⤍ FF- ⟧" +    it "tells the script the universe before the request" $+      answers+        (scripting "'𝑒' in lines[0] && 'id' in lines[1] ? '⟦ Δ ⤍ FF- ⟧' : '⟦ Δ ⤍ 00- ⟧'")+        "⟦ Δ ⤍ FF- ⟧"+     -- Neither payload carries syntax sugar, whatever '--sweet' says about the     -- output of the run, so a script finds every datum spelled as a Δ binding     it "spells the payloads as canonical 𝜑-calculus" $       answers-        "const {b} = JSON.parse(require('fs').readFileSync(0, 'utf8'));\-        \process.stdout.write(JSON.stringify({n: b.includes('Δ ⤍ 01-') ? '⟦ Δ ⤍ FF- ⟧' : '⟦ Δ ⤍ 00- ⟧'}))"+        (scripting "request['𝑏'].includes('Δ ⤍ 01-') ? '⟦ Δ ⤍ FF- ⟧' : '⟦ Δ ⤍ 00- ⟧'")         "⟦ Δ ⤍ FF- ⟧" +    -- A script started for the fire is asked one request, the first, so it+    -- may answer without reading anything at all     it "reads a script that says nothing to stdin without waiting for it" $-      answers "process.stdout.write(JSON.stringify({n: '⟦ Δ ⤍ 01- ⟧'}))" "⟦ Δ ⤍ 01- ⟧"+      answers "process.stdout.write(JSON.stringify({id: 1, '𝑛': '⟦ Δ ⤍ 01- ⟧'}))" "⟦ Δ ⤍ 01- ⟧" +    -- The stdin of a script started for the fire closes behind the request,+    -- so a script that reads line by line answers and quits on its own, the+    -- same as it would were it kept for the run+    it "lets a script that reads line by line answer and quit on its own" $+      answers counting "⟦ Δ ⤍ 01- ⟧"+     it "fails with the script's own complaint when it exits non-zero" $       fails         "process.stderr.write('no idea what to do');process.exit(4)"         ["L_answer", "exit code 4", "no idea what to do"] +    -- A script is judged by its exit status even once it has answered, since+    -- an answer it did not stand behind is no answer+    it "fails when the script answers and then exits non-zero" $+      fails+        "process.stdout.write(JSON.stringify({id: 1, '𝑛': '⟦ Δ ⤍ 2A- ⟧'}) + '\\n');process.exit(2)"+        ["L_answer", "exit code 2"]+     it "fails when the script writes something other than JSON" $       fails "process.stdout.write('almost')" ["L_answer", "almost"] -    it "fails when the script writes JSON with no 'n' in it" $-      fails "process.stdout.write(JSON.stringify({m: '⟦ ⟧'}))" ["L_answer", "n"]+    it "fails when the script writes JSON with no '𝑛' in it" $+      fails "process.stdout.write(JSON.stringify({id: 1, m: '⟦ ⟧'}))" ["L_answer", "𝑛"] -    it "fails when what the script put under 'n' is not a 𝜑-expression" $-      fails "process.stdout.write(JSON.stringify({n: '⟦ ⟧⟧'}))" ["L_answer"]+    it "fails when the script answers another request" $+      fails "process.stdout.write(JSON.stringify({id: 7, '𝑛': '⟦ Δ ⤍ 2A- ⟧'}))" ["L_answer", "request 7"]++    it "fails when what the script put under '𝑛' is not a 𝜑-expression" $+      fails "process.stdout.write(JSON.stringify({id: 1, '𝑛': '⟦ ⟧⟧'}))" ["L_answer"]++    -- An executable atom is spawned as it is, under no interpreter, so phino+    -- stages nothing of it and the file speaks the same protocol a script does+    it "runs an executable λ function straight off its path" $+      executes "echo '{\"id\": 1, \"𝑛\": \"⟦ Δ ⤍ 2A- ⟧\"}'" "⟦ Δ ⤍ 2A- ⟧"++    it "speaks the same lines to an executable as to a script" $+      executes+        "case \"$(cat)\" in *'\"λ\":\"L_answer\"'*) echo '{\"id\": 1, \"𝑛\": \"⟦ Δ ⤍ FF- ⟧\"}';; *) echo '{\"id\": 1, \"𝑛\": \"⟦ Δ ⤍ 00- ⟧\"}';; esac"+        "⟦ Δ ⤍ FF- ⟧"++    -- A program kept for the run is asked over the streams of one process,+    -- whatever runs it, so a script that counts its requests sees them all+    it "keeps a script that serves across the fires" $+      withNode $+        withRegistered (registryOf ["L_answer"] (served (scripted counting))) $ \registry -> do+          _ <- firedFrom registry "L_answer" "⟦ y ↦ ⟦ Δ ⤍ 02- ⟧ ⟧"+          second <- firedFrom registry "L_answer" "⟦ y ↦ ⟦ Δ ⤍ 02- ⟧ ⟧"+          wanted <- parseExpressionThrows "⟦ Δ ⤍ 02- ⟧"+          second `shouldBe` wanted++    it "hands back the 𝜑-expression the resident program wrote under '𝑛'" $+      serves (replying "2A-") "⟦ Δ ⤍ 2A- ⟧"++    it "keeps one resident program across the fires" $+      withShell $+        withServed ["L_answer"] (replying "0$n-") $ \registry -> do+          _ <- firedFrom registry "L_answer" "⟦ y ↦ ⟦ Δ ⤍ 02- ⟧ ⟧"+          second <- firedFrom registry "L_answer" "⟦ y ↦ ⟦ Δ ⤍ 02- ⟧ ⟧"+          wanted <- parseExpressionThrows "⟦ Δ ⤍ 02- ⟧"+          second `shouldBe` wanted++    -- One file may be registered under several λ names, and it is one program+    -- that serves them all, not one per name+    it "serves every λ name registered on the same file from one program" $+      withShell $+        withServed ["L_answer", "L_other"] (replying "0$n-") $ \registry -> do+          _ <- firedFrom registry "L_answer" "⟦ y ↦ ⟦ Δ ⤍ 02- ⟧ ⟧"+          second <- firedFrom registry "L_other" "⟦ y ↦ ⟦ Δ ⤍ 02- ⟧ ⟧"+          wanted <- parseExpressionThrows "⟦ Δ ⤍ 02- ⟧"+          second `shouldBe` wanted++    -- One key matching many names is the way to have one program serve them+    -- all without spelling it once per name+    it "serves every λ name one key matches from one program" $+      withShell $+        withServed [".*"] (replying "0$n-") $ \registry -> do+          _ <- firedFrom registry "L_answer" "⟦ y ↦ ⟦ Δ ⤍ 02- ⟧ ⟧"+          second <- firedFrom registry "L_other" "⟦ y ↦ ⟦ Δ ⤍ 02- ⟧ ⟧"+          wanted <- parseExpressionThrows "⟦ Δ ⤍ 02- ⟧"+          second `shouldBe` wanted++    it "tells the resident program the universe under '𝑒' before the first request" $+      serves (replying "0$e-") "⟦ Δ ⤍ 01- ⟧"++    it "does not tell the resident program a universe it was told already" $+      withShell $+        withServed ["L_answer"] (replying "0$e-") $ \registry -> do+          _ <- firedFrom registry "L_answer" "⟦ y ↦ ⟦ Δ ⤍ 02- ⟧ ⟧"+          second <- firedFrom registry "L_answer" "⟦ y ↦ ⟦ Δ ⤍ 02- ⟧ ⟧"+          wanted <- parseExpressionThrows "⟦ Δ ⤍ 01- ⟧"+          second `shouldBe` wanted++    it "tells the resident program the universe again when it changes" $+      withShell $+        withServed ["L_answer"] (replying "0$e-") $ \registry -> do+          _ <- firedFrom registry "L_answer" "⟦ y ↦ ⟦ Δ ⤍ 02- ⟧ ⟧"+          second <- firedFrom registry "L_answer" "⟦ z ↦ ⟦ Δ ⤍ 03- ⟧ ⟧"+          wanted <- parseExpressionThrows "⟦ Δ ⤍ 02- ⟧"+          second `shouldBe` wanted++    it "fails when the resident program answers another request" $+      refuses "printf '{\"id\": 99, \"𝑛\": \"⟦ Δ ⤍ 2A- ⟧\"}\\n'" ["L_answer", "request 99"]++    it "fails with the resident program's own complaint when it quits non-zero" $+      refuses "echo 'no idea what to do' >&2; exit 4" ["L_answer", "exit code 4", "no idea what to do"]++    it "fails when the resident program quits without answering" $+      refuses "exit 0" ["L_answer", "without answering"]++    it "fails when the resident program writes something other than JSON" $+      refuses "echo almost" ["L_answer", "almost"]++  describe "closeRegistry" $ do+    -- The program is told to quit by its stdin closing, which its read loop+    -- notices, so it gets to run whatever it does on exit+    it "stops the resident program the registry has started" $+      withShell $ do+        dir <- getTemporaryDirectory+        let mark = dir </> "phino-resident-quit"+        removePathForcibly mark+        withServed ["L_answer"] ("trap 'touch " <> T.pack mark <> "' EXIT; " <> replying "2A-") $ \registry -> do+          _ <- firedFrom registry "L_answer" "⟦ y ↦ ⟦ Δ ⤍ 02- ⟧ ⟧"+          closeRegistry registry+          doesFileExist mark `shouldReturn` True++    it "leaves a registry that started no program alone" $+      closeRegistry emptyRegistry `shouldReturn` ()
test/CLISpec.hs view
@@ -12,10 +12,11 @@ import Control.Monad (forM_, unless) import Data.Char (isDigit) import Data.List (intercalate, isInfixOf, isPrefixOf, sort)+import Data.Text qualified as T import Data.Time.Clock (addUTCTime, getCurrentTime) import Data.Time.Clock.POSIX (getPOSIXTime) import Data.Version (showVersion)-import Fixtures (withFixtureRegistry, withNode)+import Fixtures (withFixtureRegistry, withNode, withServing, withShell) import GHC.IO.Handle import Paths_phino (version) import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getTemporaryDirectory, listDirectory, removeDirectoryRecursive, removeFile, removePathForcibly, setModificationTime)@@ -398,6 +399,16 @@           length files `shouldBe` 4           doesFileExist (dir ++ "/00001.phi") `shouldReturn` True           doesFileExist (dir ++ "/00003.phi") `shouldReturn` True++    -- A served atom is asked over the streams of one resident program that+    -- 'phino' starts on the first fire and stops when the run is over, so the+    -- whole of it goes through the command line here: registry, program and+    -- the bytes it answers with+    it "dataizes with an atom served by a resident program" $+      withShell $+        withServing (T.pack "printf '{\"id\": %s, \"𝑛\": \"⟦ Δ ⤍ 2A- ⟧\"}\\n' \"$id\"") $ \registry ->+          withStdin "⟦ @ ↦ ⟦ λ ⤍ L_answer ⟧ ⟧" $+            testCLISucceeded ["dataize", "--atoms=" ++ registry] ["2A-"]      it "saves dataize steps to dir with --steps-dir" $       withAtoms $ \atoms ->
test/Fixtures.hs view
@@ -6,22 +6,36 @@ -- The λ functions the specs fire. phino implements none of them, so a spec that -- needs an atom to answer brings its own: one JavaScript fixture, -- 'test-resources/atoms/primitives.js', registered under every name in--- 'fixtureAtoms' and branching on the one it is handed as its first--- command-line argument.-module Fixtures (fixtureAtoms, fixtureRegistry, withFixtureRegistry, withNode) where+-- 'fixtureAtoms' and branching on the one each request names under 'λ', or a+-- POSIX shell script written for the occasion, either run once per fire or+-- kept resident for the run.+module Fixtures+  ( fixtureAtoms+  , fixtureRegistry+  , resident+  , withExecutable+  , withFixtureRegistry+  , withNode+  , withRegistryOf+  , withScript+  , withServing+  , withShell+  , withTemp+  )+where -import Atoms (Atom (..), Registry, Runtime (RtNode))+import Atoms (Registry, readRegistry) import Control.Exception (bracket)-import Data.Aeson (encode, object, (.=))+import Data.Aeson (Value, encode, object, (.=)) import Data.Aeson.Key qualified as Key import Data.ByteString qualified as BS import Data.ByteString.Lazy qualified as BSL-import Data.Map.Strict qualified as Map import Data.Maybe (isNothing) import Data.Text qualified as T-import Data.Text.Encoding (decodeUtf8)-import System.Directory (findExecutable, getTemporaryDirectory, removePathForcibly)+import Data.Text.Encoding (decodeUtf8, encodeUtf8)+import System.Directory (findExecutable, getPermissions, getTemporaryDirectory, removePathForcibly, setOwnerExecutable, setPermissions) import System.IO (Handle, hClose, openBinaryTempFile)+import System.Info (os) import Test.Hspec (Expectation, pendingWith)  -- Every λ function the fixture answers for. A name outside this list is@@ -41,27 +55,26 @@ fixtureScript :: IO T.Text fixtureScript = decodeUtf8 <$> BS.readFile "test-resources/atoms/primitives.js" --- The registry the specs that drive 'Dataize' directly run against.+-- The registry the specs that drive 'Dataize' directly run against: the same+-- file '--atoms' reads, read once and gone. fixtureRegistry :: IO Registry-fixtureRegistry = do-  script <- fixtureScript-  pure (Map.fromList [(name, Atom RtNode script) | name <- fixtureAtoms])+fixtureRegistry = withFixtureRegistry readRegistry  -- The same registry as the JSON file '--atoms' reads, in a temporary file -- removed afterwards, for the specs that go through the command line. withFixtureRegistry :: (FilePath -> IO a) -> IO a withFixtureRegistry action = do   script <- fixtureScript-  dir <- getTemporaryDirectory-  bracket (openBinaryTempFile dir "phino-atoms-.json") discarded $ \(path, handle) -> do-    BSL.hPut handle (encode (object [Key.fromText name .= entry script | name <- fixtureAtoms]))-    hClose handle-    action path+  withRegistryOf (object [Key.fromText name .= entry script | name <- fixtureAtoms]) action   where+    entry :: T.Text -> Value     entry script = object ["rt" .= ("node" :: T.Text), "script" .= script]-    discarded :: (FilePath, Handle) -> IO ()-    discarded (path, handle) = hClose handle >> removePathForcibly path +-- The given JSON, as the registry file '--atoms' reads, in a temporary file+-- removed afterwards.+withRegistryOf :: Value -> (FilePath -> IO a) -> IO a+withRegistryOf registry = withTemp "phino-atoms-.json" (BSL.toStrict (encode registry))+ -- Every atom the fixture provides runs under 'node', so a machine without it -- cannot fire one at all: such an expectation is pending rather than red. withNode :: Expectation -> Expectation@@ -70,3 +83,63 @@   if isNothing node     then pendingWith "'node' is not installed, so no λ function can be fired"     else expectation++-- A POSIX shell script is executable nowhere on Windows, so a case that needs+-- one is pending there rather than red.+withShell :: Expectation -> Expectation+withShell expectation+  | os == "mingw32" = pendingWith "no POSIX shell script is executable on Windows"+  | otherwise = expectation++-- A file in the temporary directory holding the given POSIX shell script,+-- removed afterwards.+withScript :: T.Text -> (FilePath -> IO a) -> IO a+withScript script = withTemp "phino-exec-.sh" (encodeUtf8 (T.unlines ["#!/bin/sh", script]))++-- The same file, executable, which is what an 'exec' or a 'serve' atom names+-- and phino never stages itself.+withExecutable :: T.Text -> (FilePath -> IO a) -> IO a+withExecutable script action = withScript script $ \path -> do+  permissions <- getPermissions path+  setPermissions path (setOwnerExecutable True permissions)+  action path++-- The registry of one λ function, 'L_answer', kept for the run, as the JSON+-- file '--atoms' reads, together with the resident program it names: a POSIX+-- shell script built of the given per-request snippet (see 'resident'). Both+-- files are removed afterwards.+withServing :: T.Text -> (FilePath -> IO a) -> IO a+withServing snippet action =+  withExecutable (resident snippet) $ \program ->+    withRegistryOf (object ["L_answer" .= object ["rt" .= ("exec" :: T.Text), "path" .= program, "serve" .= True]]) action++-- A program as a POSIX shell script that reads phino's lines until its stdin+-- closes, so it serves started once per fire and kept for the run alike: it+-- counts the universes it is told in 'e' and runs the given snippet for every+-- request, with the request in 'line', its number in 'id' and how many+-- requests it has seen so far in 'n'.+resident :: T.Text -> T.Text+resident snippet =+  T.unlines+    [ "e=0"+    , "n=0"+    , "while IFS= read -r line; do"+    , "  case \"$line\" in"+    , "    *'\"𝑒\"'*) e=$((e+1));;"+    , "    *) n=$((n+1)); id=$(printf '%s' \"$line\" | sed 's/.*\"id\":\\([0-9]*\\).*/\\1/'); " <> snippet <> ";;"+    , "  esac"+    , "done"+    ]++-- Write the content to a fresh temporary file, hand its path to the action and+-- delete the file afterwards.+withTemp :: String -> BS.ByteString -> (FilePath -> IO a) -> IO a+withTemp template content action = do+  dir <- getTemporaryDirectory+  bracket (openBinaryTempFile dir template) discarded $ \(path, handle) -> do+    BS.hPut handle content+    hClose handle+    action path+  where+    discarded :: (FilePath, Handle) -> IO ()+    discarded (path, handle) = hClose handle >> removePathForcibly path