packages feed

plexus-synapse 3.17.3 → 4.0.0

raw patch · 37 files changed

+6008/−554 lines, 37 filesdep ~aesondep ~plexus-protocolPVP ok

version bump matches the API change (PVP)

Dependency ranges changed: aeson, plexus-protocol

API changes (from Hackage documentation)

- Synapse.Hold: socketPathFor :: Text -> FilePath -> Text -> IO FilePath
- Synapse.IR.Read: [rrStreaming] :: RootRead -> Bool
- Synapse.IR.Types: [mdStreaming] :: MethodDef -> Bool
- Synapse.IR.Types: inferStreaming :: TypeDef -> Bool
- Synapse.IR.Types: isStreaming :: MethodDef -> Bool
- Synapse.Schema.Types: [methodStreaming] :: MethodSchema -> Bool
+ Synapse.CLI.Parse: TakesBoolean :: FlagArity
+ Synapse.CLI.Parse: TakesNext :: FlagArity
+ Synapse.CLI.Parse: TakesUnlessFlag :: FlagArity
+ Synapse.CLI.Parse: data FlagArity
+ Synapse.CLI.Parse: takeFlag :: (Text -> FlagArity) -> Text -> [Text] -> (Text, Maybe Text, Int)
+ Synapse.Hold: Held :: SubstrateConfig -> Text -> Int -> Double -> Held
+ Synapse.Hold: [heldAgent] :: Held -> Text
+ Synapse.Hold: [heldCalls] :: Held -> Int
+ Synapse.Hold: [heldConfig] :: Held -> SubstrateConfig
+ Synapse.Hold: [heldIdle] :: Held -> Double
+ Synapse.Hold: [hoCap] :: HoldOpts -> Int
+ Synapse.Hold: [hoVersion] :: HoldOpts -> Text
+ Synapse.Hold: [roSocket] :: RouterOpts -> FilePath
+ Synapse.Hold: [roVersion] :: RouterOpts -> Text
+ Synapse.Hold: data Held
+ Synapse.Hold: defaultCap :: Int
+ Synapse.Hold: holdOpts :: FilePath -> Double -> HoldOpts
+ Synapse.Hold: holdProtocol :: Int
+ Synapse.Hold: holderCallAs :: FilePath -> Text -> Maybe Double -> (Text -> IO ()) -> SubstrateConfig -> CallRoute
+ Synapse.Hold: holderCallDuplex :: FilePath -> Text -> Maybe Double -> (Text -> IO ()) -> SubstrateConfig -> DuplexRoute
+ Synapse.Hold: holderSocketPath :: IO FilePath
+ Synapse.Hold: holderStatus :: FilePath -> IO (Maybe Value)
+ Synapse.Hold: instance GHC.Classes.Eq Synapse.Hold.ConnKey
+ Synapse.Hold: instance GHC.Classes.Ord Synapse.Hold.ConnKey
+ Synapse.Hold: muxCallAs :: Mux -> Text -> Maybe Double -> SubstrateConfig -> DuplexRoute
+ Synapse.Hold: muxCallDuplex :: Mux -> SubstrateConfig -> DuplexRoute
+ Synapse.Hold: muxHolding :: Mux -> IO [Held]
+ Synapse.Hold: newMuxWith :: Int -> Double -> IO Mux
+ Synapse.Hold: olderThan :: Text -> Text -> Bool
+ Synapse.IR.Builder: MissingSchema :: Path -> IRBuildError
+ Synapse.IR.Builder: TitleCollision :: Text -> Text -> TypeSource -> TypeSource -> IRBuildError
+ Synapse.IR.Builder: TypeSource :: Text -> Text -> TypeSource
+ Synapse.IR.Builder: [msPath] :: IRBuildError -> Path
+ Synapse.IR.Builder: [tcFirst] :: IRBuildError -> TypeSource
+ Synapse.IR.Builder: [tcNamespace] :: IRBuildError -> Text
+ Synapse.IR.Builder: [tcSecond] :: IRBuildError -> TypeSource
+ Synapse.IR.Builder: [tcTitle] :: IRBuildError -> Text
+ Synapse.IR.Builder: [tsMethod] :: TypeSource -> Text
+ Synapse.IR.Builder: [tsSchema] :: TypeSource -> Text
+ Synapse.IR.Builder: buildIRFromSchema :: Text -> Path -> PluginSchema -> Either IRBuildError IR
+ Synapse.IR.Builder: buildIRFromSchemas :: Text -> [Path] -> Map Path PluginSchema -> Either IRBuildError IR
+ Synapse.IR.Builder: data IRBuildError
+ Synapse.IR.Builder: data TypeSource
+ Synapse.IR.Builder: instance GHC.Classes.Eq Synapse.IR.Builder.IRBuildError
+ Synapse.IR.Builder: instance GHC.Classes.Eq Synapse.IR.Builder.TypeSource
+ Synapse.IR.Builder: instance GHC.Show.Show Synapse.IR.Builder.IRBuildError
+ Synapse.IR.Builder: instance GHC.Show.Show Synapse.IR.Builder.TypeSource
+ Synapse.IR.Builder: renderIRBuildError :: IRBuildError -> Text
+ Synapse.IR.Read: readKindIn :: Text -> KeyMap Value -> Text -> Value -> Maybe TypeKind
+ Synapse.IR.Read: rootNamed :: Text -> Value -> Maybe (Text, Value)
+ Synapse.IR.Read: siteError :: Text -> Text
+ Synapse.IR.Read: siteProgress :: Text -> Text
+ Synapse.IR.Types: CardinalityMany :: Cardinality
+ Synapse.IR.Types: CardinalityOne :: Cardinality
+ Synapse.IR.Types: [mdCardinality] :: MethodDef -> Cardinality
+ Synapse.IR.Types: [mdError] :: MethodDef -> Maybe TypeRef
+ Synapse.IR.Types: [mdProgress] :: MethodDef -> Maybe TypeRef
+ Synapse.IR.Types: data Cardinality
+ Synapse.Identity.Session: hasFailed :: IO Bool
+ Synapse.Identity.Session: noteFailed :: IO ()
+ Synapse.Input: FedAll :: Fed
+ Synapse.Input: FedBadLine :: Int -> Text -> Fed
+ Synapse.Input: FedFailed :: Text -> Fed
+ Synapse.Input: FromFile :: FilePath -> InputSource
+ Synapse.Input: FromStdin :: InputSource
+ Synapse.Input: InputFlags :: Maybe Text -> Maybe Int -> Bool -> InputFlags
+ Synapse.Input: InputNames :: Text -> Text -> Text -> InputNames
+ Synapse.Input: ItemBytes :: ItemKind
+ Synapse.Input: ItemJson :: ItemKind
+ Synapse.Input: ItemString :: ItemKind
+ Synapse.Input: TakesBoolean :: FlagArity
+ Synapse.Input: TakesNext :: FlagArity
+ Synapse.Input: TakesUnlessFlag :: FlagArity
+ Synapse.Input: Tally :: !Integer -> !Integer -> !Maybe Integer -> !Integer -> !Integer -> !Bool -> !Maybe (Text, Maybe Integer) -> Tally
+ Synapse.Input: [ifChunk] :: InputFlags -> Maybe Int
+ Synapse.Input: [ifProgress] :: InputFlags -> Bool
+ Synapse.Input: [ifSource] :: InputFlags -> Maybe Text
+ Synapse.Input: [inChunk] :: InputNames -> Text
+ Synapse.Input: [inProgress] :: InputNames -> Text
+ Synapse.Input: [inStream] :: InputNames -> Text
+ Synapse.Input: [tAcked] :: Tally -> !Maybe Integer
+ Synapse.Input: [tClosed] :: Tally -> !Maybe (Text, Maybe Integer)
+ Synapse.Input: [tCredit] :: Tally -> !Integer
+ Synapse.Input: [tEnded] :: Tally -> !Bool
+ Synapse.Input: [tFrames] :: Tally -> !Integer
+ Synapse.Input: [tItems] :: Tally -> !Integer
+ Synapse.Input: [tUnacked] :: Tally -> !Integer
+ Synapse.Input: closeExit :: Maybe (Text, Maybe Integer) -> Maybe (Text, Maybe Integer) -> Maybe (Int, Text)
+ Synapse.Input: data Fed
+ Synapse.Input: data FlagArity
+ Synapse.Input: data InputFlags
+ Synapse.Input: data InputNames
+ Synapse.Input: data InputSource
+ Synapse.Input: data ItemKind
+ Synapse.Input: data Tally
+ Synapse.Input: defaultChunk :: Int
+ Synapse.Input: dryRunFrame :: Text -> ItemKind -> Int -> InputSource -> IO (Either Text Text)
+ Synapse.Input: inputNames :: [Text] -> InputNames
+ Synapse.Input: inputRefusal :: PlexusStreamItem -> Maybe (Text, Maybe Integer)
+ Synapse.Input: instance GHC.Classes.Eq Synapse.Input.Fed
+ Synapse.Input: instance GHC.Classes.Eq Synapse.Input.InputFlags
+ Synapse.Input: instance GHC.Classes.Eq Synapse.Input.InputNames
+ Synapse.Input: instance GHC.Classes.Eq Synapse.Input.InputSource
+ Synapse.Input: instance GHC.Classes.Eq Synapse.Input.Tally
+ Synapse.Input: instance GHC.Exception.Type.Exception Synapse.Input.BadLine
+ Synapse.Input: instance GHC.Show.Show Synapse.Input.BadLine
+ Synapse.Input: instance GHC.Show.Show Synapse.Input.Fed
+ Synapse.Input: instance GHC.Show.Show Synapse.Input.InputFlags
+ Synapse.Input: instance GHC.Show.Show Synapse.Input.InputNames
+ Synapse.Input: instance GHC.Show.Show Synapse.Input.InputSource
+ Synapse.Input: instance GHC.Show.Show Synapse.Input.Tally
+ Synapse.Input: itemKind :: Value -> ItemKind
+ Synapse.Input: maxChunk :: Int
+ Synapse.Input: renderTally :: Tally -> Text
+ Synapse.Input: sourceOf :: Text -> FilePath -> Maybe FilePath -> Text -> Either Text InputSource
+ Synapse.Input: takeInputFlags :: InputNames -> (Text -> FlagArity) -> [Text] -> Either Text (InputFlags, [Text])
+ Synapse.Input: withInputFeed :: ItemKind -> Int -> Bool -> InputSource -> ((InputHalf, IO Bool) -> IO a) -> IO (Either SomeException a, Fed, Tally)
+ Synapse.Schema.Types: [methodCardinality] :: MethodSchema -> Cardinality
+ Synapse.Schema.Types: [methodError] :: MethodSchema -> Maybe Value
+ Synapse.Schema.Types: [methodHttpMethod] :: MethodSchema -> Text
+ Synapse.Schema.Types: [methodProgress] :: MethodSchema -> Maybe Value
+ Synapse.Schema.Types: [psRequest] :: PluginSchema -> Maybe Value
+ Synapse.Schema.Types: [psSelfHash] :: PluginSchema -> Maybe PluginHash
+ Synapse.Schema.Types: jsonLine :: ServiceStreamItem -> ByteString
+ Synapse.Schema.Types: serviceErrorMessage :: ServiceStreamItem -> Maybe Text
+ Synapse.SchemaStore: isStaleItem :: ServiceStreamItem -> Bool
+ Synapse.Transport: fetchServiceIR :: Path -> SynapseM (Maybe (Either Text IR))
+ Synapse.Transport: invokeWithInput :: CallShape -> IO (Maybe Headers) -> ItemKind -> Int -> Bool -> InputSource -> Path -> Text -> Value -> (IO Bool -> PlexusStreamItem -> IO ()) -> SynapseM ()
+ Synapse.Transport: rootSchemaKnown :: SynapseM (Maybe PluginSchema)
- Synapse.Hold: HoldOpts :: FilePath -> Double -> HoldOpts
+ Synapse.Hold: HoldOpts :: FilePath -> Double -> Int -> Text -> HoldOpts
- Synapse.Hold: RouterOpts :: Maybe (SubstrateConfig -> IO (Maybe FilePath)) -> Double -> RouterOpts
+ Synapse.Hold: RouterOpts :: Maybe (SubstrateConfig -> IO (Maybe Text)) -> FilePath -> Double -> Text -> RouterOpts
- Synapse.Hold: [roHold] :: RouterOpts -> Maybe (SubstrateConfig -> IO (Maybe FilePath))
+ Synapse.Hold: [roHold] :: RouterOpts -> Maybe (SubstrateConfig -> IO (Maybe Text))
- Synapse.Hold: holderCall :: FilePath -> SubstrateConfig -> CallRoute
+ Synapse.Hold: holderCall :: FilePath -> Text -> SubstrateConfig -> CallRoute
- Synapse.Hold: routedCall :: RouterOpts -> Mux -> IORef Bool -> IORef (Map ConnKey (Maybe FilePath)) -> SubstrateConfig -> CallRoute
+ Synapse.Hold: routedCall :: RouterOpts -> Mux -> IORef Bool -> IORef (Map ConnKey (Maybe Text)) -> IORef Bool -> SubstrateConfig -> DuplexRoute
- Synapse.IR.Builder: extractMethodDef :: Text -> Text -> MethodSchema -> (Map Text TypeDef, MethodDef)
+ Synapse.IR.Builder: extractMethodDef :: Text -> Text -> MethodSchema -> Either IRBuildError (Map Text TypeDef, MethodDef)
- Synapse.IR.Builder: extractMethodDefR :: Text -> Text -> MethodSchema -> ((Map Text TypeDef, MethodDef), [Refusal])
+ Synapse.IR.Builder: extractMethodDefR :: Text -> Text -> MethodSchema -> Either IRBuildError ((Map Text TypeDef, MethodDef), [Refusal])
- Synapse.IR.Read: RootRead :: Map Text TypeDef -> TypeRef -> Bool -> RootRead
+ Synapse.IR.Read: RootRead :: Map Text TypeDef -> TypeRef -> RootRead
- Synapse.IR.Types: MethodDef :: Text -> Text -> Text -> Maybe Text -> Bool -> [ParamDef] -> TypeRef -> Maybe TypeRef -> Maybe TypeRef -> Maybe Value -> MethodRole -> Maybe Text -> Maybe Text -> Maybe Text -> [InjectedParam] -> Maybe TypeRef -> MethodDef
+ Synapse.IR.Types: MethodDef :: Text -> Text -> Text -> Maybe Text -> [ParamDef] -> TypeRef -> Maybe TypeRef -> Maybe TypeRef -> Cardinality -> Maybe TypeRef -> Maybe TypeRef -> Maybe Value -> MethodRole -> Maybe Text -> Maybe Text -> Maybe Text -> [InjectedParam] -> Maybe TypeRef -> MethodDef
- Synapse.Schema.Types: MethodSchema :: Text -> Text -> PluginHash -> Maybe Value -> Maybe Value -> Bool -> Bool -> Maybe Value -> Maybe Value -> Maybe DeprecationInfo -> Maybe [ParamSchema] -> MethodRole -> Maybe Text -> Maybe Text -> Maybe Text -> Maybe [Text] -> Bool -> Maybe Text -> [MethodExample] -> Maybe Value -> MethodSchema
+ Synapse.Schema.Types: MethodSchema :: Text -> Text -> PluginHash -> Maybe Value -> Value -> Maybe Value -> Maybe Value -> Cardinality -> Bool -> Text -> Maybe Value -> Maybe Value -> Maybe DeprecationInfo -> Maybe [ParamSchema] -> MethodRole -> Maybe Text -> Maybe Text -> Maybe Text -> Maybe [Text] -> Bool -> Maybe Text -> [MethodExample] -> Maybe Value -> MethodSchema
- Synapse.Schema.Types: PluginSchema :: Text -> Text -> Text -> Maybe Text -> PluginHash -> [MethodSchema] -> Maybe [ChildSummary] -> Maybe DeprecationInfo -> Maybe (Map Text [Text]) -> Maybe View -> PluginSchema
+ Synapse.Schema.Types: PluginSchema :: Text -> Text -> Text -> Maybe Text -> Maybe PluginHash -> PluginHash -> [MethodSchema] -> Maybe [ChildSummary] -> Maybe DeprecationInfo -> Maybe Value -> Maybe (Map Text [Text]) -> Maybe View -> PluginSchema
- Synapse.Schema.Types: [methodReturns] :: MethodSchema -> Maybe Value
+ Synapse.Schema.Types: [methodReturns] :: MethodSchema -> Value

Files

+ CHANGELOG.md view
@@ -0,0 +1,60 @@+# Changelog for plexus-synapse++## 4.0.0++Everything since 3.17.3 (3.17.4 was never published; its change is listed here).++### Breaking++- **IR 3.0, on the plexus 0.7 schema.** Requires `plexus-protocol >= 0.6`.+  A method's `returns`, `error`, `progress` and `cardinality` are read from+  its schema, never guessed; servers on the published 0.7.0 line are still+  read, cardinality taken from the method's own `streaming` flag. A title+  collision (two types under one title) refuses that activation by name+  (`-i` exits 65 and says what was left out) instead of failing the whole+  walk.+- **Holder protocol 4.** The connection holder's socket is+  `$XDG_RUNTIME_DIR/synapse/holder-4.sock`. A 4.0 run never uses or+  replaces a 3.x holder (`holder-2.sock`), nor the reverse; the two run+  side by side.+- **Schema cache moved** to `$XDG_CACHE_HOME/synapse/schemas-4`+  (`~/.cache/synapse/schemas-4`). 3.x's `schemas` directory is never read,+  so a 4.0 run and a rolled-back 3.x never share an entry. The first 4.0+  run against a service fetches its schemas again.+- **A failed stream exits non-zero.** An error item, or a data item whose+  content has `type: error`, sets exit status 1. Services report their+  failures in band; scripts that relied on exit 0 after such a stream will+  now see 1.+- **One holder per user**, not one per address and agent. The agent rides+  in each request; a connection whose server is gone is let go at once; at+  most 256 connections are held, the longest idle closed first; the newest+  synapse takes over holding.++### Added++- **Streaming input (`Input<T>`, plexus-wire spec/10).** `--input -` (stdin)+  or `--input @FILE` sends a method's input half: lines, NDJSON items, or+  raw bytes as binary frames (`--chunk N`, 4096 by default, at most+  16 MiB), sent as the service grants credit, ended at EOF, carried through+  the holder. A disconnect is reported with sent / acked / unacked counts+  and never resent (exit 69); typed closes exit 70, 65 or 77, a failed read+  74. `--progress` prints sent / acked / credit on stderr once a second. A+  flag whose name a method parameter already has (`input`, `chunk`,+  `progress`) becomes `--input-stream`, `--input-chunk` or+  `--input-progress`. The IR reads a bytes input as string/binary.+- **The service's own `ir` first.** When a service's root schema lists an+  `ir` method, synapse asks it for the whole IR in one call instead of one+  schema call per activation; otherwise it walks the raw schemas as before.+  A 0.7.0 service spends no call on it.+- The server's own error message is shown and kept; an unknown error kind+  is rendered `kind: message`, and a missing grant as the Rust side writes+  it. Re-sign-in and cache repair work against both 0.8 and 0.7.0 servers.++### Changed++- `cabal.project` no longer lists `../plexus-protocol`; it comes from+  Hackage.+- The suites that need a live service (`cli-test`, `websocket-raw-test`,+  `ir-test`, `bidir-integration-test`) are opt-in behind the manual flag+  `live-tests` (`cabal test -f live-tests`), so a default `cabal test`+  needs nothing running.
+ README.md view
@@ -0,0 +1,1199 @@+# Synapse++**A CLI that writes itself from your API.**++Synapse is the command-line frontend for [Plexus RPC](../plexus-protocol/) — a streaming JSON-RPC 2.0 protocol built for LLM orchestration. Define methods in Rust, and Synapse discovers them at runtime: commands, help text, parameter validation, and output rendering all come from the schema. No codegen, no static config.++```+                  ┌─────────────┐+                  │  Substrate   │  Rust backend service+                  │  (Plexus)    │  hosts activations+                  └──────┬──────┘+                         │ WebSocket JSON-RPC 2.0+                         │ streaming responses+                  ┌──────┴──────┐+                  │   Synapse    │  Haskell CLI+                  │  discovers   │  builds commands from schema+                  │  & invokes   │  renders streaming output+                  └─────────────┘+```++## Two problem domains synapse solves++1. **Schema-driven invocation.** Method discovery, parameter parsing, help text, and output rendering are all derived live from the backend's JSON Schema. No codegen, no static config — change the backend, the CLI follows.++2. **Credential lifecycle.** Per-backend defaults at `~/.plexus/<backend>/defaults.json` hold cookies and headers as **credential-reference URIs** (`literal:`, `env://`, `file://`, `keychain://`) resolved at request time. JWT decoding surfaces expiry inline (`expired 16d ago`), legacy `~/.plexus/tokens/<backend>` files auto-migrate, and the `_self` subcommand lets you inspect, set, and rotate credentials without ever pasting them into a shell. See [Credentials & Headers](#credentials--headers).++## The Full Picture: Define → Call++### 1. Define a method in Rust++An **activation** is a namespace of related methods. You define one by annotating an impl block:++```rust+use schemars::JsonSchema;+use serde::{Deserialize, Serialize};++// Event type — what the method streams back+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]+#[serde(tag = "event", rename_all = "snake_case")]+pub enum EchoEvent {+    Echo { message: String, count: u32 },+}++// The activation+pub struct Echo;++#[plexus_macros::activation(+    namespace = "echo",+    version = "1.0.0",+    description = "Echo messages back"+)]+impl Echo {+    #[plexus_macros::method(+        description = "Echo a message back the specified number of times",+        params(+            message = "The message to echo",+            count = "Number of times to repeat (default: 1)"+        )+    )]+    async fn echo(+        &self,+        message: String,+        count: u32,+    ) -> impl Stream<Item = EchoEvent> + Send + 'static {+        stream! {+            for i in 0..count {+                yield EchoEvent::Echo {+                    message: message.clone(),+                    count: i + 1,+                };+            }+        }+    }++    #[plexus_macros::method(+        description = "Echo a message once",+        params(message = "The message to echo")+    )]+    async fn once(+        &self,+        message: String,+    ) -> impl Stream<Item = EchoEvent> + Send + 'static {+        stream! {+            yield EchoEvent::Echo { message, count: 1 };+        }+    }+}+```++The macro generates the JSON-RPC server, `Activation` trait impl, method enum, and JSON Schemas — all from the function signatures and `JsonSchema` derives. Register it with the service:++```rust+let service = DynamicService::new("substrate")+    .register(Echo::new())+    .register(Health::new())+    .register(Cone::new());+```++That's the entire backend. No route tables, no handler boilerplate, no schema files.++### 2. Call it from Synapse++```bash+# Install+cd synapse && cabal install++# Synapse discovers the echo activation and its methods automatically+$ synapse substrate echo once --message "hello"+message: hello+count: 1++$ synapse substrate echo echo --message "hello" --count 3+message: hello+count: 1++message: hello+count: 2++message: hello+count: 3+```++Every method you add to the Rust backend is immediately available as a CLI command. The parameter names, types, descriptions, and validation all come from the schema.++### 3. See what's on the wire++```bash+# Dry-run: show the JSON-RPC request without sending+$ synapse --dry-run substrate echo echo --message "hello" --count 3+{"method":"echo.echo","params":{"message":"hello","count":3}}++# JSON mode: see the raw stream items+$ synapse --json substrate echo echo --message "hello" --count 3+{"type":"data","content":{"event":"echo","message":"hello","count":1}}+{"type":"data","content":{"event":"echo","message":"hello","count":2}}+{"type":"data","content":{"event":"echo","message":"hello","count":3}}+{"type":"complete"}++# Fetch the JSON Schema for the activation+$ synapse --schema substrate echo+{"namespace":"echo","methods":[...]}+```++## The Registry++Plexus backends register themselves with a **registry** — a central service that tracks what's running and where. Synapse talks to the registry by default (at `localhost:4444`), so you never need to know host:port pairs for individual backends. You just use the backend by name:++```bash+# This just works — the registry resolves "substrate" to its host:port+synapse substrate echo once --message "hello"+```++When you run `synapse` with no arguments, it queries the registry and lists everything available:++```bash+$ synapse+Available backends:+  substrate      127.0.0.1:4444 [OK]+  lforge         127.0.0.1:4447 [OK]+  secrets        127.0.0.1:4446 [OK]+```++If your backend isn't showing up, it needs to register with the registry. Synapse also auto-registers backends it connects to directly — so if you point at a non-default port once, it becomes available by name for future calls:++```bash+# Direct connection (also registers with the registry at :4444)+synapse -P 4447 lforge hyperforge repos_list --org juggernaut++# Now this works without -P+synapse lforge hyperforge repos_list --org juggernaut+```++The key mental model: **don't think in terms of hosts and ports.** Register your backends, then use them by name. The registry is the source of truth.++## A service behind TLS: `--url wss://…` (SCAFFOLD-21, 3.15.0)++A hosted app sits behind a proxy that terminates TLS and routes its socket+at a path — every app on the house's edge is `wss://<app>.menger.sh/ws`.+Name it with `--url`, before the backend name like every global option:++```bash+synapse --url wss://hostdemo.menger.sh/ws hostdemo greeter hello --name Ada+synapse --url wss://hostdemo.menger.sh/ws -j hostdemo greeter count --to 3+synapse --url wss://hostdemo.menger.sh/ws -s hostdemo greeter        # the schema+synapse --url wss://hostdemo.menger.sh/ws                            # the backend there, [OK]+synapse -H hostdemo.menger.sh --tls --path /ws hostdemo greeter hello --name Ada   # the same address+```++| option | meaning |+| -- | -- |+| `--url ws[s]://HOST[:PORT][/PATH]` | the whole address; the port defaults to the scheme's (80, 443), the path to `/`. Not with `-H`/`-P`/`--tls`/`--path`; `http(s)://` is refused with the `ws(s)://` it means |+| `--tls` | dial `wss://` at `-H`/`-P` (port 443 unless `-P`) |+| `--path PATH` | the socket's path at `-H`/`-P` (default `/`) |+| `--cacert FILE` | with wss, also trust the CA certificate(s) in this PEM file (repeatable) |++**The certificate is always verified**: the chain against the system's trust+store (the distribution's bundle; `SYSTEM_CERTIFICATE_PATH` points synapse at+another directory or file) and the name against the host, which is also sent+as SNI. A failed check fails the handshake before any of the request — a+token included — is sent. There is no switch that skips the check: for a local+CA (a throwaway proxy, `caddy … tls internal`), pass its root with `--cacert`.++**Everything rides the same address**: the `_info` probe, calls, streams and+their reconnects (SYNAPSE-20 — the outage line names the URL), `-s`, `-i`,+`_find`, bidirectional requests and their `respond`, `_login` / `_logout` and the+session every later call carries. The upgrade's `Host` is the host alone on 443+(`host:port` otherwise). A registry lists plain `ws://HOST:PORT` backends, so an+address behind TLS or on a path is never auto-registered: name it each time,+or put `--url` in a shell alias.++**Sessions are kept per URL.** A plain `ws://HOST:PORT/` keeps the key it+always had (`~/.plexus/sessions/HOST_PORT/`, keychain `backend=HOST:PORT`), so+no stored session moves. Any other address is keyed by its URL with the port+written — `~/.plexus/sessions/wss_hostdemo.menger.sh_443_ws/AGENT.json`,+keychain `backend=wss://hostdemo.menger.sh:443/ws` — so a ws and a wss backend+on one host, or two paths, never share a session. Sign in with the options+the calls use:++```bash+synapse --url wss://app.example/ws _login app --key builder --as builder+synapse --url wss://app.example/ws app tickets health     # carries builder's session+```++An agent's key proof names the server's socket origin as its audience:+`ws://HOST:PORT` on a plain address (as before), `wss://HOST` (`:PORT` when+not 443) behind TLS — what a service run with `--public-url https://HOST` accepts+(plexus-login's `socket_urls`). The path is not part of it.++## Progressive Discovery++Once you're connected, just start typing. Synapse navigates the backend like a filesystem — each level shows what's available, so you never need external docs:++```bash+# What can substrate do?+$ synapse substrate+  arbor               Tree-structured conversation storage+  bash                Execute bash commands+  cone                Conversational AI sessions+  echo                Echo messages back+  health              Health check endpoint++# What methods does echo have?+$ synapse substrate echo+  echo                Echo a message back the specified number of times+  once                Echo a message once++# What does cone chat need? (auto-help when required params are missing)+$ synapse substrate cone chat+chat - Conversational interaction with a cone++  --identifier.type <string> (required)+      Discriminator for cone lookup (by_id, by_name)++  --identifier.name <string> (required if type=by_name)+  --prompt <string> (required)+      User message to send++# Inspect the raw JSON Schema for an activation+$ synapse --schema substrate cone+```++### Help and search: `_find`++A listing (`synapse -P 4455 menger`, `… menger tickets`) is one line per+activation and method; a method's page (`… tickets set_status --help`) is its+usage, aligned options and examples (SYNAPSE-12, SYNAPSE-13). When you don't+know the name, search for it (SYNAPSE-17):++```bash+$ synapse -P 4455 _find menger comit+5 matches for 'comit' (as 'commit') in menger++METHODS+  tickets link_commits      Link commits to a ticket, or to many+  tickets unlink_commits    Unlink commits from a ticket++PARAMETERS+  deployments record        [--commits <string[]>]    Every commit in previous..revision…+  tickets link_commits      --commits <string[]>      Commit references, in link order+  tickets unlink_commits    --commits <string[]>      Commit references to remove…++Run 'synapse -P 4455 menger tickets link_commits --help' for its options.+```++`synapse [OPTIONS] _find <backend> [activation…] <words…> [--limit N] [--methods | --params]`++- Every method and parameter whose name or description matches **all** the+  words, best first: an exact name, then a name's prefix (or a `snake_case`+  segment's), then an abbreviation (`setpar` → `set_parent`, prefixes of its+  segments in order) or a typo (one edit for 4–7 letters, two beyond), then a+  word of a description. Ties: the shorter path, then alphabetical.+- Grouped `METHODS` · `PARAMETERS` · `MENTIONED IN` (found only in a+  description; left out for a method already listed above). A parameter line is+  its method, its flag as the method's page shows it (`[brackets]` when+  optional) and its description, with `[values: …]` under it.+- The header says what a typo or an abbreviation was taken as; on a terminal+  the matched characters are bold (piped, or with `NO_COLOR`, plain text).+- Activation names before the words narrow it: `_find menger tickets setpar`.+  `--limit N` (default 20; the footer says how many more), `--methods` /+  `--params` for one group. Nothing found: the three nearest names, exit 1.+- `-j` (before `_find`): one JSON object per hit — `kind` (method | param),+  `path`, `param`, `type`, `required`, `description`, `values`, `group`,+  `score`, `matched`.+- Under login it searches the caller's view of the schema: a method the caller+  can't see is never a hit. The IR is kept in `~/.cache/synapse/ir` under the+  root schema's hash (and the caller's view), so a search costs one schema call+  while nothing has changed; `--no-cache` walks the backend again.++## Parameters++Synapse parses flags into typed JSON using the schema's IR:++```bash+# Simple flags+synapse substrate echo echo --message "hello" --count 3++# Dotted keys for nested objects+synapse substrate cone chat \+  --identifier.type by_name \+  --identifier.name my-assistant \+  --prompt "hello"++# Lists — repeated flags or comma-separated (see Lists below)+synapse substrate tags set --tags backend --tags critical+synapse substrate tags set --tags backend,critical,urgent+synapse substrate tags set --tags '"red, green",blue'   # an element with a comma++# Boolean flags (bare flag = true)+synapse substrate cone create --ephemeral+synapse substrate cone create --ephemeral=false++# A value may begin with dashes; --key=value works too+synapse substrate echo once --message "--- starts with dashes"+synapse substrate echo once --message @note.md++# Raw JSON override+synapse substrate cone chat -p '{+  "identifier": {"type": "by_name", "name": "my-assistant"},+  "prompt": "hello"+}'+```++Path parameters (`--path`, `--working_dir`, etc.) expand `~`, resolve relative paths, and substitute `$ENV` variables automatically. **No other value is touched**: `--body 'costs $HOME'` sends `costs $HOME`. (Before 3.14.2 every value had `$VAR` substituted — a ticket body read from a file with `--body "$(cat body.md)"` lost its literal `$HOME`, `$PATH`, … The shell already expands what you leave unquoted, so synapse doing it again only ever rewrote text you had protected.)++### Flag values++Once synapse has found the method, it reads each flag by its parameter's type:++| typed | read as |+| -- | -- |+| `--body VALUE` | the next argument is the value, whatever it begins with: `---`, `--force`, `-5`, `--`, or nothing (`''`) |+| `--body=VALUE` | everything after the first `=`, which may be empty or contain `=` |+| `--body @body.md` | the file's contents, UTF-8, exactly as they are (3.14.8, SYNAPSE-10): no argument carries the value, so a body may be as large as the backend accepts; `~/` and a relative path resolve as the shell's would; a file that cannot be read is a refusal naming the flag and the path |+| `--body -` | standard input, read once however many values ask for it; it cannot be combined with `--bidir-mode json`, which reads stdin too |+| `--handle @@alice` | the text `@alice`: one `@` is dropped, so a value that really begins with `@` can still be sent |+| `--summary` (a boolean) | `true`; it takes the next argument only when that is `true` or `false` (or `1`, `0`), so `--summary --limit 3` is two flags |+| `--summary=false` | `false` |+| `--no-such x` (names no parameter) | `Unknown parameter: --no-such`, nothing is sent; such a flag takes the next argument unless it begins with `--` |++So a markdown body with frontmatter passes as itself: `--body @body.md`, or `--body "$(cat body.md)"` while it is under the shell's 128 KB argument cap. The same sources serve `-p`: `-p @params.json` and `-p -`. Only the flag's name is normalized (`--blocked-by` is `blocked_by`), never its value. An argument that no flag takes (`--summary yes`) is an error, and nothing is sent. `--help` is always help, even right after a flag: to send the text `--help`, write `--body=--help`. Synapse's own options (`-P`, `-n`, `--json`, …) still go before the backend name.++**Changed in 3.14.1.** Through 3.14.0 a value that began with `--` was read as the next flag (`--body "---…"` failed with `Unknown parameter: --_`), any flag followed by another flag was bare whatever its type, and `--body=…` was read as a flag named `body=…`.++### Lists++A list parameter takes its elements one flag at a time, several to a flag, or both. When the elements are scalars (strings, integers, numbers, booleans, or a named type that is one of those, such as an id or a string enum), each flag value is read as one comma-separated record, with double quotes for an element that contains a comma:++| flag value as synapse receives it | elements |+| -- | -- |+| `a,b,c` | `a` `b` `c` |+| `a` | `a` |+| `"a,b,c,d","e,f,g"` | `a,b,c,d` and `e,f,g` — a double-quoted element keeps its commas |+| `"say ""hi""",b` | `say "hi"` and `b` — `""` inside quotes is one quote |+| `a, b ,c` | `a` `b` `c` — space around an unquoted element is dropped |+| `a,,b,` | `a` `b` — an empty unquoted element is dropped |+| `"",a` | the empty string and `a` |+| `"a,b` or `"a"x,b` | an error naming the parameter and the value; nothing is sent |++Repeated flags append in order: `--tags a,b --tags c` is `a` `b` `c`. Elements are then typed as before (`--ids 1,2,3` sends `[1,2,3]` for an integer list). A list of objects, of lists, or of untyped values is not split — each flag value is one element, since JSON has commas of its own. Nothing else splits: a comma in `--title` is just a comma, and `-p '{…}'` is sent as written. `<method> --help` marks each list parameter with the forms it takes.++**The shell removes double quotes before synapse runs**, so an element that contains a comma needs its quotes protected by single quotes:++| typed | what synapse receives |+| -- | -- |+| `--commits a,b,c,e` | `a,b,c,e` — four elements |+| `--multi "a,b,c,d","e,f,g"` | `a,b,c,d,e,f,g` — seven elements; the shell dropped the quotes |+| `--multi '"a,b,c,d","e,f,g"'` | `"a,b,c,d","e,f,g"` — two elements |++**Changed in 3.14.0.** Through 3.13 every flag value was exactly one element, commas included (`--tags a,b` sent `["a,b"]`). Now a single element that contains a comma must be quoted (`--tags '"a,b"'`), and a value that begins with `"` is read as a quoted element.++## Credentials & Headers++Synapse sends a JWT as `Cookie: access_token=<jwt>` on WebSocket upgrade when one is available, plus any extra cookies or headers you specify. All of these come from a single per-backend defaults file with a priority chain for per-invocation overrides.++### Defaults store++Each backend has a JSON file at `~/.plexus/<backend>/defaults.json` holding default cookies and headers. Values are **credential-reference URIs**, not raw secrets — synapse resolves them at request time.++```json+{+  "version": 1,+  "defaults": {+    "cookies": {+      "access_token": "literal:eyJhbGc..."+    },+    "headers": {+      "X-API-Key": "env://MY_API_KEY"+    }+  },+  "scopes": {}+}+```++Supported schemes:++| Scheme | Example | Meaning |+|---|---|---|+| `literal:` | `literal:eyJ...` | Raw value verbatim after the colon. |+| `env://` | `env://MY_JWT` | Read from environment variable. |+| `file://` | `file:///abs/path` | File contents (trailing newline stripped). |+| `keychain://` | `keychain://uscis/access_token` | OS keychain item (macOS via `security` CLI; Linux/Windows return a "not implemented" error). |++File mode is content-aware: `0600` if any `literal:` value is present, `0644` if the file is a pure manifest of references.++#### Threat model for `keychain://`++The keychain resolver shells out to `/usr/bin/security`. Items it creates have ACLs scoped to the `security` binary itself, not to synapse — meaning **any process running as your user can read them silently** by shelling out to `security` with the matching service/account. What `keychain://` actually protects against:++- Other users on the same machine+- Plaintext-on-disk leaks (the file contains only URIs; `defaults.json` becomes commit-safe when no `literal:` refs are present)+- Disk theft (the keychain DB is encrypted at rest, unlocked at login)++What it does **not** protect against:++- Any process running as you that knows the service/account (`security find-generic-password -s plexus -a <…> -w` works without a prompt)+- A compromised synapse binary++This is the standard tradeoff for "shell out to `security`" tools (cargo, gcloud, aws-cli all do the same). If you need per-process sandboxing, the upgrade path is to use the Security framework C API directly with code-signing-anchored ACLs — not implemented in v1.++### Managing defaults: the `_self` subcommand++```bash+# Inspect current state — decodes JWTs, flags expired tokens+synapse _self <backend> show++# Set a value (auto-wrapped as literal: unless you provide a scheme)+synapse _self <backend> set cookie access_token "eyJ..."+synapse _self <backend> set header X-API-Key "env://MY_API_KEY"++# Read from stdin (always literal:, bypasses the scheme heuristic)+cat my-jwt.txt | synapse _self <backend> set-from-stdin cookie access_token++# Import a JWT file into cookies.access_token+synapse _self <backend> import-token ~/Downloads/jwt.txt++# Resolve a single value (debug): prints the resolved concrete value+synapse _self <backend> resolve cookie access_token++# Remove entries+synapse _self <backend> unset cookie access_token+synapse _self <backend> clear --yes+```++`show` decodes JWT-shaped values and prints `alg`, `kid`, `iss`, `aud`, `sub`, `exp` (with human-relative rendering like `"expired 15d 23h ago"` or `"valid for 4m 32s"`), `iat`, `preferred_username`, `email`. Signatures are never printed.++### Per-invocation overrides++Four CLI paths populate credentials at call time, highest priority first:++```bash+# --token takes a JWT, sends it as Cookie: access_token=<jwt>+synapse --token eyJ... <backend> <method>++# SYNAPSE_TOKEN env+SYNAPSE_TOKEN=eyJ... synapse <backend> <method>++# --token-file reads from a path+synapse --token-file ~/tokens/uscis <backend> <method>++# --cookie / --header add arbitrary key/values on the upgrade request+synapse --cookie session=abc --header X-Trace-Id=xyz <backend> <method>+```++Invocation flags override the defaults store per key. CLI flags apply only to that one call; they don't write to disk.++### Legacy token file++The pre-SELF convention was `~/.plexus/tokens/<backend>` holding a raw JWT. On the first `loadDefaults` call for a backend that has a legacy file but no `defaults.json`, synapse auto-migrates: the JWT becomes `cookies.access_token = literal:<jwt>` in the new file, and the legacy file is deleted. An INFO log line names both paths and suggests upgrading to keychain storage.++## Output Rendering++Synapse renders streaming responses through a Mustache template pipeline:++```bash+# Default: schema-aware pretty output+synapse substrate health check+status: healthy+uptime: 12345++# --raw: content JSON only (skip template rendering)+synapse --raw substrate health check+{"status":"healthy","uptime":12345}++# --json: full JSON-RPC stream items+synapse --json substrate health check+{"type":"data","content":{"status":"healthy","uptime":12345}}+```++Templates are resolved in order: `.substrate/templates/{namespace}/{method}.mustache` (project-local) → `~/.config/synapse/templates/` (user) → YAML-like fallback.++Generate templates from the schema:++```bash+synapse --generate-templates substrate+```++### The answer in a file: `-o FILE` (SYNAPSE-39, 3.17.3)++An answer you mean to read goes to a file, then you read the file — never+through a filter that may stop reading early (a `grep` that cuts a list short,+a `head` that closes on a write):++```bash+synapse -P 4455 -o /tmp/t.json -j menger tickets list --summary true+synapse -P 4455 --output /tmp/t.txt menger tickets get --id MENGER-12+```++- `-o FILE` / `--output FILE` is a global option (before the service name,+  as `-j`). What the call prints goes to FILE instead of stdout, in the same+  form a pipe would get (`-j` lines with `-j`, the rendered answer without);+  stdout gets nothing, stderr what it always gets.+- It is written to a temporary file beside FILE and renamed over FILE when+  the call ends with 0, so a reader never sees half an answer. FILE is+  `0600` (an answer may carry a session token — `session login`).+- A call that ends with anything else leaves no FILE — an older FILE is+  removed too, so nothing stale is read as this call's answer — and no+  temporary file. The exit code is the call's (75, 77, 69…).+- A stream (`watch`) has no end: its items are appended to FILE and flushed+  as each comes. A stream that fails keeps what it wrote.+- A write's answer is written whole, whoever reads it.+- What must be seen while the call waits goes to **stderr**, not FILE: a+  two-way request (the interactive prompt, `--bidir-mode json`'s and+  `respond`'s request lines — the answer still goes to FILE), and under+  `-j` an error or a refusal (its `-j` line goes with the failed run's FILE,+  so a refused call says why on stderr and exits 77).+- `-o -` is stdout. A FILE that cannot be made (no such directory, a+  directory) is said on stderr and the run **exits 73** before anything is+  sent; a write to it that fails later (a full disk, a file size limit) is+  no outage — the call ends, unretried — and the run **exits 74**.+- A SIGTERM or SIGHUP ends the run as a failed one (143, 129): no FILE and+  no temporary file. A SIGKILL can leave the temporary file+  (`FILE<n>.tmp`) behind.++## Outages: reconnecting (SYNAPSE-20)++synapse rides out a backend that goes away — a restart, a promote, a+network blip — for **ten minutes** by default.++- **A lost connection is noticed**: the server closing it, a reset, or+  silence. While a call is open, synapse pings after 15 s with nothing heard+  and counts the connection lost when 10 s more pass without an answer+  (`PLEXUS_KEEPALIVE=IDLE[,TIMEOUT]` in seconds, `0` for none). A call is+  never left waiting on a dead socket.+- **It retries for the window**: 0.5 s, doubling, to 15 s between tries.+  `--reconnect-for 10m` (`90s`, `1h`, `0` = never; or+  `SYNAPSE_RECONNECT_FOR`) sets how long. A backend that is down when synapse+  starts is waited for the same way (a wrong port that answers something+  other than Plexus fails at once). Progress goes to **stderr only**:++  ```+  synapse: connection to 127.0.0.1:4455 lost (the connection was closed) — reconnecting for up to 10m+  synapse: retrying 127.0.0.1:4455 in 4s (4s of 10m)+  synapse: reconnected to 127.0.0.1:4455 after 8s+  ```++- **A read is sent again; a write is not.** A method is a read when its+  schema's `group` is `read` (else when its name is a read's: `get`, `list`,+  `watch`, …); anything else, and anything bidirectional, is a write. A+  write whose request went out before the connection was lost may have+  happened, so synapse says so and **exits 75** (`connection to … lost+  before an answer — the write may have happened; check before sending it+  again`). A write that never left (the backend was down when it was+  sent) is sent once the backend is back.+- **A stream resumes.** `watch`, `await_reply` and any other streaming read+  are subscribed again with the same params. A method that takes `since`+  (menger's `await_reply`) gets `since` = the unix time the call began+  unless you gave one, so an answer given during the outage is still+  delivered. Under `-j` a line marks the resume, in the stream on stdout:++  ```json+  {"type":"reconnected","outage_secs":8,"attempts":4,"since":1792204276}+  ```++  (`since` only when the resumed call carried it.) The resumed stream+  starts as a new call does — `await_reply` says `waiting` again. Without+  `since`, events during the gap may be missing: re-read what you need.+  Other modes say it on stderr.+- **After the window**, synapse **exits 69** with the reason (`… unreachable+  for 10m (connection refused) — giving up`). It never hangs.+- **A closed stdout is not an outage** (SYNAPSE-26, 3.17.2). When the+  reader of synapse's stdout goes — `… | head -1`, `… | grep -m1` — synapse+  stops at once and **exits 141** (what a shell says of a filter its pipe+  closed on), saying nothing: no retry, no reconnect, a stream unsubscribed,+  a write sent once. Whatever window `--reconnect-for` sets, and on `-j` and+  the plain printer alike. (Before 3.17.2 the EPIPE was taken for a lost+  connection and a write was sent again for as long as the pipe stayed+  closed.) The socket's own broken pipe — the service gone — is still an+  outage. A stream notices its reader is gone only when it next prints: once+  `tickets watch | head -1` has its line, synapse keeps running until the+  next event arrives, then exits 141 — with no event, it waits. A run+  already ending with a code of its own — 75, 77, 69 — keeps it; 141 only+  replaces a success.++Many agents' calls at once ride out a restart too (SYNAPSE-15): a call+whose stored session the restart ended signs in again from its key under a+lock on that session, and a call that waited for the lock takes the session+the first one made. Sixteen or thirty-two parallel calls after a restart+sign in once and all succeed; a connection refused for the server's+connection limits (429) is waited out like any outage.++| exit | meaning |+| -- | -- |+| 69 | the backend stayed unreachable for the whole window |+| 75 | a write went out and its answer never came — it may have happened |+| 77 | the service refused the call (AUTH-8) |+| 73 | `-o FILE` could not be made (SYNAPSE-39): nothing was sent |+| 74 | a write to `-o`'s FILE failed (SYNAPSE-39): the call ended there |+| 141 | stdout's reader closed the pipe (SYNAPSE-26): nothing more was sent |++Through the connection holder (SYNAPSE-28, below) all of this holds as it is.++## One round trip a call (SYNAPSE-28, 3.16.0)++A call costs one round trip and the server's time. Against app.menger.sh+(77 ms away) `tickets health` takes **81 ms**, where it took 4.6 s.++```+$ synapse --url wss://app.menger.sh/ws --as builder menger tickets health   # the first: 2.6 s+$ synapse --url wss://app.menger.sh/ws --as builder menger tickets health   # every one after: 81 ms+```++Two things make it so:++- **A connection held between runs**, as ssh's ControlMaster holds one. The+  first call starts a holder (`synapse _hold`, a process of synapse's own)+  that keeps the signed-in WebSocket open and listens on a unix socket:+  `$XDG_RUNTIME_DIR/synapse/holder-4.sock` (0600, in a 0700 directory of+  yours). Later runs hand it their requests and read the replies back, so+  the TCP, TLS and upgrade handshakes (about 0.3 s to a hosted service) are+  paid once. Since 3.17.4 it is **one holder for all your calls**+  (SYNAPSE-40, below), a connection in it per address and agent (`--as`),+  never shared between agents; it exits after **10 minutes** holding nothing+  (`--hold-for 30m`, or `SYNAPSE_HOLD_FOR`). `--no-hold` (`SYNAPSE_NO_HOLD=1`)+  skips it: that run dials one connection of its own and closes it. A call+  that brings its own credential (`-t`, `--token-file`, `--cookie`,+  `--header`, `PLEXUS_SESSION`) never uses a holder.+- **Send first, check on failure.** synapse keeps what a call needs from the+  backend in `~/.cache/synapse/schemas-4/` (`$XDG_CACHE_HOME`): where the+  backend is, its `_info` as your agent was answered, each activation's+  schema (`PATH@VERSION-HASH.json`, pinned by `PATH.pin`) and a **plan** per+  method (its schema, its activation's deprecation, the types its flags parse+  by: kilobytes, where menger's `tickets` schema is 2.6 MB). A call to a+  method it has a plan for is built from the plan and sent: no probe, no+  schema fetch. The probe and the fetch run, on the same connection, only+  when that fails as the schema explains: a method or flag the cache does not+  know, or the server refusing what the cache said (a method it does not+  have, under the AUTH gate one only admin holds; params with a field+  missing, unknown or of another type). Those refusals come before the+  method runs, so the call is then sent again, a write too; the plans it+  misled are forgotten. Help, listings, `-s`, `-i` and `_find` always read+  the server.++Everything else is as before. Outages are ridden out by the same rules, a+read sent again and a write never resent blind (exits 69 and 75), because+the holder reports a lost connection, and whether the request had gone out,+exactly as a connection of the run's own would. A session that ended is made+again from the key by the run, and the holder dials a connection with the+new one beside the old, closing the old when idle. A run that goes away+mid-stream (Ctrl-C) ends its call at the server. Twenty runs starting at+once share one connection, and the reads they all ask first (the schema,+`_info`) are asked once.++Measured against app.menger.sh (ping 77 ms), 2026-10-22:++| | 3.15.0 | 3.16.0 |+| -- | -- | -- |+| `tickets health`, warm | 4,615 ms | 81 ms |+| `tickets queued`, warm | 7,398 ms | 84 ms |+| 20 `tickets health` at once, warm | 31.6 s | 107 ms |+| the first call (no holder, nothing cached) | — | 2.6 s |+| the first call after the holder exited (cache warm) | — | 380 ms |+| `--no-hold`, cache warm | — | 382 ms |+| 20 at once, no holder, nothing cached | — | 3.1 s |++The 0.9 s that went before every call in 3.15.0 was a second `_info` probe,+made anonymously on a connection of its own to check the name the first+probe had already answered; a run now probes at most once.++## One holder for the user (SYNAPSE-40, 3.17.4)++Through 3.17.3 the holder was one process per address and agent, each+living its 10 idle minutes after its server had gone; a test loop calling+throwaway servers on fresh ports left one per port, 1,634 of them (24.9 GB)+on 2026-11-02. Now one process holds every connection your runs keep,+however many services and agents you call — `synapse _hold`, at+`$XDG_RUNTIME_DIR/synapse/holder-4.sock` — and inside it a connection per+address, agent and credential. A call as one agent never rides another+agent's connection, even when neither carries a credential; a session made+again from the key is a connection beside the old one, which closes once+idle.++- **A connection whose server is gone is let go at once.** A server that+  stops closes its end; the holder notices within a second (a close or a+  reset ends the connection's reader; a peer gone silent is caught by the+  keepalive, 15 s idle + 10 s) and holds nothing for it from then on. The+  next call dials again, and a dial that fails holds nothing, so riding out+  an outage stays the run's (SYNAPSE-20: a read sent again, a write never+  resent blind, exits 69 and 75).+- **Idle, closed.** A connection is kept idle for the `--hold-for` of the+  last call on it (10 minutes unless the call says), then closed; the+  holder exits once it has held nothing, with no call, for its own+  `--hold-for` (the run that started it said).+- **A cap**: 256 connections (`SYNAPSE_HOLD_MAX`, read when the holder+  starts; 0: none). Past it the one idle longest is closed first; one+  carrying a call never is, so a burst of open calls may pass it until they+  end. A live connection costs the holder about 96 kB (403 held: 17.2 MB →+  55.8 MB), so at the cap it stays under about 45 MB.+- **What it holds**: `synapse _hold status` prints it as JSON — its pid,+  version and protocol, the cap, and each connection's address, agent,+  calls under way and seconds idle (never a credential); exit 1 when no+  holder answers.+- **SIGTERM / SIGHUP** stop it taking runs at once and give the calls under+  way ten seconds; a stream still open then is told its connection was+  lost and resumes through the next holder (SYNAPSE-20).+- **The newest synapse holds.** The holder says its version when it takes a+  request; a run of a newer synapse then puts its own holder in its place+  (the old one lets go of the socket at once, closes what is idle,+  finishes what it carries and exits), and a run of an older one never+  replaces a newer, so the newest in use serves every run and two never+  take turns.+- **What it takes from the run that starts it**: nothing it does not need.+  It starts in `/` (it keeps no directory in use), without the run's+  `PLEXUS_KEY`, `PLEXUS_SESSION`, `SYNAPSE_TOKEN` or `PLEXUS_KEEPALIVE`+  (one run's keepalive would be every connection's). It does keep that+  run's other environment (`SYSTEM_CERTIFICATE_PATH`, say) and its cgroup:+  a holder first started inside a systemd unit stops with the unit, and+  the calls it carried then end as an outage does (a read sent again, a+  write that went out exit 75).++The socket's name carries the holder's protocol (2): a synapse that changes+the wire or what the holder does bumps it and gets a holder of its own.+synapse 3.16.0 to 3.17.3 keep their holder per address and agent+(`ADDRESS@AGENT-VERSION.sock`, in the same directory), which this one never+opens, removes or answers, so a pinned older binary goes on as it was. A+loop over throwaway servers still sets `SYNAPSE_NO_HOLD=1` while any agent+there runs one.++Measured on 2026-11-02 (hold-test, the built synapse against mock services+in the test's process):++| | 3.17.3 | 3.17.4 |+| -- | -- | -- |+| holders, 3 addresses × 2 agents | 6 | 1 |+| holders, a loop of 20 calls to 20 short-lived servers | 20 | 1 |+| a loop of 500 calls to 500 short-lived servers | — | 1 holder, 103 s; RSS 29.8 MB at call 50, 31.2 MB at 500; nothing held 10 s on |+| connections held to stopped servers, 10 s on | each holder's, for 10 min | 0 (let go within a second) |++## A stream in: `--input` (BIDIR-18)++A method with an input half (`Input<T>`, plexus-wire spec/10: its schema+has `input` beside `params`) takes a stream in beside its stream out.+stdin (`--input -`) or a file (`--input @path`) is that stream:++```bash+tail -f build.log | synapse --url wss://app.menger.sh/ws --as runner menger runs append_log --run 41 --input -+synapse -P 4460 svc ingest tokens --run r1 --input @tokens.jsonl+arecord -q -f S16_LE -r 16000 -c 1 | synapse -P 4460 voice listen --input - --chunk 640+# stderr, at the end or on a disconnect:+# input: sent 1,204 items in 1,204 frames · acked through seq 1,179 · 25 unacked+```++- **What an item is** comes from the item's schema: a string is a line; an+  `Input<Bytes>` (`"x-plexus-binary": true`) is the bytes as they come, up+  to `--chunk N` (4096 unless said, at most 16 MiB) a frame — what the+  source has is sent, never held to fill a chunk — each a WebSocket binary frame+  (`[0x01][subscription u64][seq u64][payload]`); anything else is NDJSON,+  one JSON value a line — a line that is not one is named by its number,+  no frame carries it, the call is ended and synapse exits 65.+- **Under credit**: items are read only as the service grants credit, so+  with none, synapse reads nothing more and the pipe in front of it is the+  buffer — its memory stays flat however far the writer runs ahead. What is+  ready goes out together (up to 256 items a frame), and a whole line goes+  out while the next is still arriving. At the end of the input,+  `<ns>.end` — at once, credit or none; only the end of the input ends it+  (a failed read ends the call with exit 74, no `end`). The answer prints+  as any call's.+- **Never sent twice** (spec/10: no resume). A connection lost during the+  call is said on stderr every time, with what was sent and acked, and the+  run exits 69. A typed close exits by its kind: `past_credit` 70 (a bug in+  synapse), `invalid_input` / `out_of_sequence` / `too_much_input` 65, an+  input closed for a revoked or ended sign-in 77.+- `--progress` prints sent / acked / credit on stderr once a second; `-n`+  prints the open frame and the first input frame and sends nothing; a+  method's `--help` and `_find` say it takes a stream in. A parameter named+  `input` keeps `--input` (the stream is then `--input-stream`; `--chunk`+  and `--progress` likewise become `--input-chunk` / `--input-progress`).+- The frames go through the connection holder (holder protocol 4) or, with+  `--no-hold`, on the run's own socket — the same frames either way.++## Code Generation++Synapse emits a structured Intermediate Representation for building typed clients:++```bash+$ synapse --emit-ir <backend> conformance > conformance.ir.json+```++IR 3.0 (plexus 0.7) carries each method's contract as its schema states it:+its item type (`mdReturns`), its error type when it can say no (`mdError`),+its progress type when it reports progress (`mdProgress`), and whether it+yields one item or many (`mdCardinality`). An excerpt of RETURNS-7's+conformance fixture, whose whole IR is+`plexus-macros/fixtures/conformance/expected/ir.json`:++```json+{+  "irVersion": "3.0",+  "irTypes": {+    "conformance.Loaded": {+      "tdKind": {"tag": "KindStruct", "ksFields": [+        {"fdName": "items", "fdRequired": true, "fdType": {"tag": "RefPrimitive", "contents": ["integer", "uint32"]}}+      ]}+    },+    "conformance.LoadError": {+      "tdKind": {"tag": "KindEnum", "keDiscriminator": "type", "keVariants": [+        {"vdName": "too_many", "vdFields": [+          {"fdName": "max", "fdRequired": true, "fdType": {"tag": "RefPrimitive", "contents": ["integer", "uint32"]}},+          {"fdName": "n", "fdRequired": true, "fdType": {"tag": "RefPrimitive", "contents": ["integer", "uint32"]}}+        ]}+      ]}+    }+  },+  "irMethods": {+    "conformance.load": {+      "mdCardinality": "many",+      "mdParams": [+        {"pdName": "n", "pdType": {"tag": "RefPrimitive", "contents": ["integer", "uint32"]}, "pdRequired": true}+      ],+      "mdReturns": {"tag": "RefNamed", "contents": {"qnNamespace": "conformance", "qnLocalName": "Loaded"}},+      "mdError": {"tag": "RefNamed", "contents": {"qnNamespace": "conformance", "qnLocalName": "LoadError"}},+      "mdProgress": {"tag": "RefNamed", "contents": {"qnNamespace": "conformance", "qnLocalName": "LoadStep"}}+    }+  }+}+```++Nothing is inferred: `mdCardinality` is the schema's `cardinality`, and every+`RefNamed` has a `TypeDef` — a struct, a tagged enum (`keDiscriminator` is the+property every variant gives a `const`, whatever it is called; a newtype+variant over a struct carries the struct's fields), a string enum or an alias.+One title in one namespace is one type: two methods whose schemas give a title+the same shape share one `TypeDef`, and two shapes under one title fail the+build, naming both methods. Identical types in different namespaces are then+deduplicated (prefer the parent namespace, then the shortest).++### IR 2.1: read whole, or refused by name (SYNAPSE-CC-8, 3.17.0)++Every schema is read whole or refused; nothing is dropped. The reader+(`src/Synapse/IR/Read.hs`) is pure: a value and the refusals met.++- **Unions.** A `oneOf` / `anyOf` is a string enum (every member an `enum`+  or a `const` of strings: `TransitionField` is `status, kind, archive`), a+  tagged enum (`KindEnum`, `keDiscriminator` the property every member gives+  a string `const` — `type`, `kind`, `event`, …; a newtype variant carries+  its struct's fields), an externally tagged enum (`keRepr: "external"`,+  `keDiscriminator: ""`: `{"browser": {…}}`), an untagged union of named+  types (`keRepr: "untagged"`, each variant's `vdRef`), or a nullable+  (`RefOptional`). `keRepr` is left out when internal, so 2.0's enums read+  as before. Any other union is refused, naming the member it could not read.+- **Refusals.** `irRefusals` lists each site the IR cannot type+  (`rfSite`, `rfKeyword`, `rfVariant`, `rfDetail`; left out when empty).+  Where a refused site stood the IR says `RefUnknown`; a refused type is left+  out and its uses say `RefUnknown`. With refusals, `synapse -i` still prints+  the IR on stdout, names each site on stderr and **exits 65**; no generator+  may run on it. Calls, help, flags and `_find` ignore refusals.+- **Sites** are `<ns.Type>[.<variant>].<field>`, `<method path>(<param>)`,+  `<method path> returns`, with `[]` (element), `?` (optional), `{}` (map+  value) and `.N` (tuple element): `tickets.Component.preview[].0`.+- **The JSON type.** A `serde_json::Value` is a type, not a gap: `true`,+  `{}`, annotations only, `type` naming all six JSON types (`["null",+  "boolean", "number", "string", "array", "object"]`, as plexus-core writes+  it since SYNAPSE-CC-9) or a `$ref` to a definition named `Json` is+  `RefJson` (`{"tag": "RefJson"}`): `JsonValue` / `Json` /+  `serde_json::Value` in a client. Still refused: a bare `{"type":+  "object"}` (neither properties nor additionalProperties), an array with no+  items, a type list that is neither one type (± null) nor all six. IR 2.0's+  `RefAny` reads as `RefJson`.+- **New refs.** `RefMap V` (an object with `additionalProperties: V` and no+  properties), `RefTuple [..]` (`prefixItems` with `minItems = maxItems` =+  its length), `RefRefined base {minimum, maxLength, …}` (only a bound the+  format does not imply: `minimum: 0` on a `uint64` adds nothing), and+  `RefChild "<path>"` (a static or dynamic child's return: its client). An+  rpc method with no `returns` is refused at `<path> returns`.+- **Injected params** (`x-plexus-source` other than `rpc`) are not in+  `mdParams`: they are `mdInjected` (`ipName`, `ipSource` verbatim,+  `ipDescription`; left out when empty). Help still shows them.+- **`mdInput`** is a method's `input` schema as a type, reserved for+  BIDIR-6; left out when absent (no server sends it yet).+- **The closed keyword list.** A schema in a type position uses only `type+  $ref properties required additionalProperties items prefixItems oneOf+  anyOf enum const format minimum maximum exclusiveMinimum exclusiveMaximum+  minLength maxLength pattern minItems maxItems default description title+  examples deprecated $schema $defs definitions x-plexus-source+  x-plexus-values`, and `allOf` as IR 3.0 reads it (`allOf: [X]` is X;+  `allOf: [{"$ref": S}]` beside a tag is a newtype variant over S); any+  other (`multipleOf`, an `allOf` of more, …) is refused by name.+- **Where a shared type lives.** The same type (name and structure) in+  several namespaces is kept once, in the namespace whose methods and types+  refer to it most; a tie goes to the namespace that is an ancestor of the+  others, then the shortest name, then alphabetical; the protocol root `""`+  only when no generated namespace has a copy.+- **Caches.** A plan or a `_find` catalogue holding another IR version+  (3.16.0's 2.0) is not read; it is built again.++`bench/IRBench.hs` (`cabal bench ir-bench`) times the builder alone over+menger's 2026-10-29 schemas (`test/fixtures/ir-coverage`), with no network;+`ir-coverage-test` holds one fixture per construct, menger whole (21+refusals; the IR equal to `menger-2026-10-29.ir-2.1.json`), and the guard+that every construct 2.0 already typed serializes as before.++### Servers on the published 0.7.0 line (RETURNS-36)++synapse 4.0 reads plexus 0.8's schema and frames strictly, and the+published 0.7.0 line's too, by what that server itself declares+(plexus-protocol 0.6, as plexus-core's child seam reads an old peer): a+method with `streaming` and no `cardinality` is many when the flag is+true, else one, and its missing `returns` is `{}`; an error frame with no+typed `error` is typed from its `error_kind`, else its `code` (its fields+kept); a progress frame with no `content` is `Note {message,+percentage}`. A method that states neither `cardinality` nor `streaming`+is refused. `--emit-ir` against a 0.7.0 server writes IR 3.0 with those+cardinalities.++### Where IR 3.0 is built (RETURNS-36)++- The types and the version: `src/Synapse/IR/Types.hs` — `IR`,+  `MethodDef` (`mdReturns`, `mdError`, `mdProgress`, `mdCardinality`),+  `TypeDef`/`TypeKind`/`TypeRef`, and `currentIrVersion = "3.0"`, which+  `emptyIR` and the metadata's `gmIrVersion` carry.+- The reader: `src/Synapse/IR/Read.hs` (`readRoot`, `readDefs`,+  `readType`, `rootNamed`) — each schema position read whole or refused.+- The builder: `src/Synapse/IR/Builder.hs` — `buildIR` (a walk),+  `buildIRFromSchema backend path schema` (pure: one `PluginSchema`, no+  metadata), `finalizeIR` (placement, dangling refs to `RefUnknown`,+  refusals sorted), `checkTitles` (one title, one type: `TitleCollision`).+- The golden: `test/IR3Spec.hs` builds `buildIRFromSchema "conformance"+  ["conformance"]` from `plexus-macros/fixtures/conformance/expected/schema.json`+  and compares it byte for byte (keys sorted, two-space indent, one+  newline) with `ir.json` beside it; `CONFORMANCE_FIXTURE_DIR` points it+  at a copy, `SYNAPSE_GOLDEN_ACCEPT=1` rewrites `ir.json`.++## Client Generation (synapse-cc)++[**synapse-cc**](../synapse-cc/) (Synapse Compiler Collection) is a separate project that consumes Synapse's IR to produce typed client libraries. It imports plexus-synapse as a library, generates IR from a live backend, pipes it through [service-codegen](../service-codegen/) (a stateless Rust code generator), then handles merging, dependencies, building, and testing.++```bash+synapse-cc init                    # scaffold synapse.config.json+synapse-cc build                   # generate typed client from config+synapse-cc watch substrate         # rebuild on schema changes+```++The generated client gives you typed methods matching the backend 1:1:++```typescript+const client = new SubstrateClient("ws://localhost:4444");++for await (const event of client.echo.echo({ message: "hello", count: 3 })) {+  console.log(event.message, event.count);+}+```++Three-way merge preserves user edits across regeneration. See the [synapse-cc architecture doc](docs/architecture/16673264336036332543_synapse-cc-pipeline.md) for the full pipeline, config format, and caching strategy.++## Error Messages++```bash+$ synapse substrate invalid-plugin+Command not found: 'invalid-plugin' at substrate++Available at substrate:+  Methods:+    schema              - Get schema information+    health              - Health check endpoint++  Child plugins:+    arbor               - Tree-structured conversation storage+    bash                - Execute bash commands+    cone                - Conversational AI sessions++$ synapse substrate cone chat --mesage "hello"+Unknown parameter: --mesage++Did you mean: --message?+```++## Debugging & Testing++Synapse provides built-in tools for debugging connections and validating protocol compliance.++### Connection Debugging++Test the complete connection stack (TCP → HTTP → WebSocket → RPC):++```bash+synapse _self debug localhost 5001 substrate+```++This tests each layer sequentially:+- **TCP**: Can we reach the port?+- **HTTP**: Does the server respond to HTTP?+- **WebSocket**: Can we upgrade to WebSocket?+- **RPC**: Can we call `_info`?++### Protocol Validation++Run the full protocol compliance test suite:++```bash+synapse _self validate localhost 5001 substrate+```++This calls the `_debug.*` endpoints (enabled with `PLEXUS_DEBUG=true` on the server) and validates:+- StreamDone messages are sent+- Metadata structure is correct+- Field naming follows conventions+- Progress percentages are in range++### Test Arbitrary Methods++Test any method with protocol validation:++```bash+# Use CLI flags for connection settings+synapse -P 5001 _self test substrate.ping.pong --message "test"++# Test with schema validation+synapse -P 5001 _self test substrate.echo.echo --message "hello" --count 3++# Allow unknown parameters (warns but passes through)+synapse -P 5001 _self test --allow-unknown substrate.echo.echo --fake "value"++# Use raw JSON (skips schema validation)+synapse -P 5001 _self test --raw '{"message":"hello"}' substrate.echo.echo+```++**Important**: Connection settings come from `-H`/`--host` (default: `127.0.0.1`) and `-P`/`--port` (default: `4444`) flags, not positional arguments. This ensures the test uses the same connection that discovery validated.++### Port Scanning++Discover backends running on common ports:++```bash+synapse _self scan+# Scans ports 4440-4459 for Plexus backends+```++## Architecture++Synapse treats the plugin hierarchy as a **category** and implements operations as **algebras** over recursion schemes:++```haskell+type SynapseM = ExceptT SynapseError (ReaderT SynapseEnv IO)++-- Navigate to a path (paramorphism — fold with access to original structure)+navigate :: Path -> SynapseM SchemaView++-- Walk the full tree (hylomorphism — fused unfold + fold, no intermediate tree)+walkSchema :: (SchemaF a -> SynapseM a) -> Path -> SynapseM a++-- Build IR (hylomorphism with parallel child fetching)+buildIR :: Path -> SynapseM IR+```++The base functor:++```haskell+data SchemaF a+  = PluginF PluginSchema Path [a]   -- Interior node (namespace with children)+  | MethodF MethodSchema Text Path  -- Leaf (invocable method)+```++Schemas are fetched lazily during navigation, cached by content hash, with cycle detection for recursive plugin graphs.++## Project Structure++```+synapse/+  app/Main.hs              # CLI entry, two-phase arg parsing, dispatch+  src/Synapse/+    Monad.hs               # SynapseM effect stack, error types+    Transport.hs           # WebSocket JSON-RPC bridge+    Schema/+      Types.hs             # Path, PluginSchema, MethodSchema, NavError+      Functor.hs           # SchemaF base functor, Fix+    Algebra/+      Recursion.hs         # cata, ana, hylo, para, apo (pure + monadic)+      Navigate.hs          # Path navigation (paramorphism)+      Walk.hs              # Tree walking (hylomorphism)+      Render.hs            # Schema → text rendering+    CLI/+      Parse.hs             # IR-driven --flag parsing → typed JSON+      Help.hs              # Auto-generated help from IR+      Transform.hs         # Path expansion, env var substitution+      Template.hs          # Mustache template generation from IR+    IR/+      Types.hs             # IR, TypeDef, MethodDef, TypeRef+      Builder.hs           # Schema → IR (hylomorphism + type dedup)+    Backend/+      Discovery.hs         # Registry discovery, health checks+    Self/+      Commands.hs          # _self meta-commands (scan, templates)++../plexus-protocol/        # Shared Plexus types (sibling package)+```++## Docker++Synapse can be run in a Docker container for easy deployment and testing.++### Build Docker Image++```bash+# From the synapse directory+docker build -t synapse:latest .++# Or use docker-compose+docker-compose build+```++### Run with Docker++```bash+# Show help+docker run --rm synapse:latest++# Call a method (requires --network host to access local backends)+docker run --rm --network host synapse:latest substrate echo.echo --message "hello"++# Enable debug logging+docker run --rm --network host -e LOG_LEVEL=debug synapse:latest substrate hash++# Connect to remote backend+docker run --rm synapse:latest -H 192.168.1.100 -P 5001 backend method++# Run protocol validation+docker run --rm --network host synapse:latest _self debug localhost 4444 substrate+```++### Docker Compose++```bash+# Show help+docker-compose run --rm synapse++# Run a method+docker-compose run --rm synapse synapse substrate echo.echo --message "hello"++# Debug connection+BACKEND=substrate PLEXUS_PORT=4444 docker-compose --profile debug up++# With environment variables+LOG_LEVEL=debug docker-compose run --rm synapse synapse substrate hash+```++### Image Details++- **Base**: debian:bookworm-slim (minimal runtime)+- **Size**: ~200MB (multi-stage build)+- **User**: Non-root user (uid 1000)+- **Default log level**: error (quiet mode)+- **Network**: Use `--network host` to access localhost backends++## Development++```bash+cabal build                        # Build+cabal test                         # Run tests (needs backend on localhost:4444)+cabal install                      # Install synapse binary+cabal build -f build-examples      # Build optional examples+scripts/naming-gate.sh             # The naming gate: each line that says the retired word (-c: the count)+```++A Plexus backend is a *service* (NAMING-9; `~/AGENTS.md` "Naming"):+`test-suite naming-gate` runs the gate, so `cabal test` fails the day the+retired word is written again.++## License++MIT
app/Main.hs view
@@ -71,7 +71,7 @@ import qualified Synapse.CLI.Parse as Parse import Synapse.IR.Types (IR, irMethods, irRefusals, MethodDef) import qualified Synapse.IR.Types as IRT-import Plexus.Schema.Recursive (DeprecationInfo)+import Plexus.Schema.Recursive (Cardinality(..), DeprecationInfo) import qualified Data.Map.Strict as Map import qualified Synapse.CLI.Template as TemplateIR import Synapse.Deprecation (emitActivationWarning, emitMethodWarning)@@ -101,6 +101,9 @@ import qualified Synapse.Identity.Store as Store import System.Exit (ExitCode(..), exitWith) import qualified Synapse.Find as Find+import qualified Synapse.Input as In+import Control.Monad (forM_)+import System.Directory (getCurrentDirectory) import Synapse.IR.Cache (newIRCache, defaultCacheConfig)  -- ============================================================================@@ -902,7 +905,9 @@     Right () -> do       -- AUTH-8: a refusal was shown — the call did not happen       refused' <- Session.wasRefused-      if refused' then exitWith (ExitFailure refusedExit) else exitSuccess+      if refused' then exitWith (ExitFailure refusedExit) else do+        failed <- Session.hasFailed+        if failed then exitFailure else exitSuccess   where     isStaleError = \case       Left (StaleSchema _) -> True@@ -996,18 +1001,14 @@   pure v  isStaleItem :: ServiceStreamItem -> Bool-isStaleItem item = case itemRefusal item of-  Just r -> refusalRoles r == ["admin"]-  Nothing -> case item of-    ServiceError _ _ msg _ -> any (`T.isInfixOf` msg)-      [ "Method not found", "Activation not found", "Unknown method", "method not found"-      , "errCode = -32601", "missing field", "unknown field", "invalid type", "unknown variant" ]-    _ -> False+isStaleItem = SS.isStaleItem --- | SYNAPSE-28: route every call through the connection holder for its--- address and agent (started by the first run), or — with --no-hold, a--- credential of the call's own, or no holder to be had — over one--- connection of this process's own.+-- | SYNAPSE-28: route every call through the connection holder (started+-- by the first run), or — with --no-hold, a credential of the call's own,+-- or no holder to be had — over one connection of this process's own.+-- SYNAPSE-40: one holder for the user, every address and agent; each call+-- names the agent it is made as, and the holder keeps a connection per+-- address, agent and credential. installCallRouter :: SynapseOpts -> IO () installCallRouter opts = do   noHoldEnv <- lookupEnv "SYNAPSE_NO_HOLD"@@ -1024,20 +1025,22 @@               || explicit || holdFor <= 0   st <- Store.systemStoreEnv   asked <- askedAgent opts-  let version = T.pack (showVersion Meta.version)-      cp = soEndpoint opts+  sock <- Hold.holderSocketPath+  let cp = soEndpoint opts       viaRegistry = Store.addressKey cp == Store.addressKey (plainAt defaultHost defaultPort)-      socketFor cfg = do+      agentFor cfg = do         let ep = endpointOfConfig cfg         -- the run's own address is held — or, reached through the         -- registry, whatever it names; a registry the run only glances at         -- (the auto-registration's probe) is not         if not viaRegistry && Store.addressKey ep /= Store.addressKey cp then pure Nothing else do           agent <- maybe (Store.readDefaultAgent st ep) (pure . Just) asked-          Just <$> Hold.socketPathFor version (E.dirName ep) (fromMaybe "anonymous" agent)+          pure (Just (fromMaybe "anonymous" agent))   _ <- Hold.installRouter Hold.RouterOpts-         { Hold.roHold = if off then Nothing else Just socketFor-         , Hold.roHoldFor = holdFor }+         { Hold.roHold = if off then Nothing else Just agentFor+         , Hold.roSocket = sock+         , Hold.roHoldFor = holdFor+         , Hold.roVersion = T.pack (showVersion Meta.version) }   pure ()  -- | The address a plexus-protocol config dials.@@ -1051,14 +1054,30 @@   }  -- | SYNAPSE-28: @synapse _hold --socket PATH --hold-for SECS@ — the--- connection holder (a run starts it; it exits when idle).+-- connection holder (a run starts it; it exits when it has held nothing+-- for SECS). SYNAPSE-40: one for the user; it keeps at most+-- @SYNAPSE_HOLD_MAX@ connections (256; 0: no cap), the longest idle closed+-- first. @synapse _hold status@ prints what it holds (JSON: each+-- connection's address, agent, calls and seconds idle — never a+-- credential), or exits 1 when no holder answers. runHold :: [Text] -> IO () runHold rest = case rest of   ["--socket", path, "--hold-for", secs]-    | Just d <- (readMaybe (T.unpack secs) :: Maybe Double) ->-        Hold.runHolder Hold.HoldOpts { Hold.hoSocket = T.unpack path, Hold.hoHoldFor = d }+    | Just d <- (readMaybe (T.unpack secs) :: Maybe Double) -> do+        capEnv <- lookupEnv "SYNAPSE_HOLD_MAX"+        let cap = fromMaybe Hold.defaultCap (capEnv >>= \c -> readMaybe c >>= \n -> if n >= 0 then Just n else Nothing)+        Hold.runHolder (Hold.holdOpts (T.unpack path) d)+          { Hold.hoCap = cap, Hold.hoVersion = T.pack (showVersion Meta.version) }+  ["status"] -> do+    sock <- Hold.holderSocketPath+    st <- Hold.holderStatus sock+    case st of+      Just v -> LBS.putStrLn (encode v) >> exitSuccess+      Nothing -> do+        hPutStrLn stderr ("synapse: no holder answers at " <> sock)+        exitWith (ExitFailure 1)   _ -> do-    hPutStrLn stderr "synapse _hold --socket PATH --hold-for SECS (started by synapse itself)"+    hPutStrLn stderr "synapse _hold status | _hold --socket PATH --hold-for SECS (started by synapse itself)"     exitWith (ExitFailure 2)  -- | AUTH-8: the exit status of a call the service refused (sysexits' EX_NOPERM).@@ -1105,7 +1124,7 @@          Nothing -> do           -- Parse path and inline params (--key value pairs)-          let (pathSegs, rawParams, flagArgs, helpRequested) = parsePathAndParams argPath+          let (pathSegs, rawParams, flagArgs0, helpRequested) = parsePathAndParams argPath            -- Normal Plexus RPC routing           -- Apply parameter transformations (path expansion, env vars)@@ -1115,12 +1134,31 @@           -- read once and kept for the second pass below           sourceEnv <- liftIO $ mkSourceEnv (teCwd transformEnv) (teHome transformEnv)           let fromSources pairs = liftIO (expandSources sourceEnv pairs) >>= either (throwParse . renderSourceError) pure-          sourcedParams <- if helpRequested then pure rawParams else fromSources rawParams+          -- BIDIR-18: an input stream's flag (--input -, --input @FILE)+          -- names a stream, read as the call runs — never read here; a+          -- parameter of that name is read below, once the method is known+          let streamFlag (k, _) = k `elem` ["input", "input_stream"]+              keepStreamFlags pairs = do+                expanded <- fromSources (filter (not . streamFlag) pairs)+                let go [] _ = []+                    go (p : ps) es | streamFlag p = p : go ps es+                    go (_ : ps) (e : es) = e : go ps es+                    go (_ : ps) [] = go ps []+                pure (go pairs expanded)+              -- the stream flags' values read now: for a method that has+              -- no input half, they are a parameter's+              expandStreamFlags pairs = do+                expanded <- fromSources (filter streamFlag pairs)+                let go [] _ = []+                    go (p : ps) (e : es) | streamFlag p = e : go ps es+                    go (p : ps) es = p : go ps es+                pure (go pairs expanded)+          sourcedParams <- if helpRequested then pure rawParams else keepStreamFlags rawParams           inlineParams <- liftIO $ transformParams transformEnv defaultTransformers sourcedParams            -- Mode 1.5: Respond subcommand (agent sends a response to a pending bidir request)           if not (null pathSegs) && head pathSegs == "respond"-            then handleRespondCommand sourcedParams+            then expandStreamFlags sourcedParams >>= handleRespondCommand            -- Mode 2: Schema request           else if soSchema@@ -1155,7 +1193,12 @@                   unless (null refusals) $ liftIO $ do                     TIO.hPutStrLn stderr $ "synapse: refused \x2014 " <> T.pack (show (length refusals))                       <> " site" <> (if length refusals == 1 then "" else "s")-                      <> " the IR cannot type (irRefusals); nothing was dropped:"+                      <> " the IR cannot type (irRefusals); "+                      -- RETURNS-36: a title collision leaves its activation+                      -- out (Builder.pluginIRRefusing); say which+                      <> (case [ irSite r | r <- refusals, irKeyword r == "title" ] of+                            [] -> "nothing was dropped:"+                            out -> "left out: " <> T.intercalate ", " out <> " (two types under one title):")                     let w = maximum (map (T.length . irSite) refusals)                         kw = maximum (map (T.length . irKeyword) refusals)                     mapM_ (\r -> TIO.hPutStrLn stderr $ "  " <> T.justifyLeft w ' ' (irSite r) <> "  "@@ -1206,6 +1249,14 @@                         -- Landed on a method: invoke or show help                         ViewMethod method path -> do                           let fullPath = T.intercalate "." path+                          -- BIDIR-18: a method with an input half takes its+                          -- stream (--input -/@FILE), --chunk and --progress+                          -- out of the flags before its parameters are paired+                          (inputFlags, flagArgs) <- case methodInput method of+                            Nothing -> pure (Nothing, flagArgs0)+                            Just _ -> case In.takeInputFlags (methodInputNames method) (methodFlagArity method) flagArgs0 of+                              Left e -> throwParse e+                              Right (f, rest) -> pure (Just f, rest)                            -- IR-15: emit stderr deprecation warnings right before any                           -- real invocation. Dedupe is per-process-lifetime, so this@@ -1261,12 +1312,13 @@                           -- 2. Parameter parsing (methods with params)                           let needsIR = helpRequested                                      || isJust (methodParams method)+                                     || isJust (methodInput method)                                      || isJust soParams                                      || not (null inlineParams)                            -- Fast path: parameter-less method with no help requested                           if not needsIR-                            then fireDeprecationWarnings >> keepPlan Nothing >> invokeMethod sourceEnv method path (object [])+                            then fireDeprecationWarnings >> keepPlan Nothing >> invokeMethod sourceEnv inputFlags method path (object [])                             else do                               -- Build IR for this method's namespace                               -- (SYNAPSE-28: the plan's, when it has one)@@ -1322,7 +1374,13 @@                                             Nothing | cached -> stale "a method the cache's IR lacks"                                             Nothing ->                                               -- Fallback to flat object if method not in IR-                                              pure $ buildParamsObject inlineParams+                                              -- BIDIR-18: an input method's own flags are no+                                              -- parameter; any other's --input is+                                              case methodInput method of+                                                Just _ -> let names = methodInputNames method+                                                              own (k, _) = k `elem` map (T.replace "-" "_") [In.inStream names, In.inChunk names, In.inProgress names]+                                                          in pure $ buildParamsObject (filter (not . own) inlineParams)+                                                Nothing -> buildParamsObject <$> expandStreamFlags inlineParams                                       | otherwise -> pure $ object []                                   -- Merge: user params override schema defaults                                   let params = mergeParams schemaDefaults userParams@@ -1331,8 +1389,15 @@                                     then do                                       backend <- asks seBackend                                       liftIO $ LBS.putStrLn $ encodeDryRun backend (init path) (last path) params+                                      -- BIDIR-18: and the first input frame it would send+                                      forM_ (methodInput method) $ \schema -> do+                                        (_, kind, chunk, _, src) <- inputCall fullPath method schema inputFlags+                                        first <- liftIO $ In.dryRunFrame backend kind chunk src+                                        case first of+                                          Right frame -> liftIO $ TIO.putStrLn frame+                                          Left e -> throwParse e                                     -- Show help when: method has required params AND user provided no params-                                    else if hasRequiredParams method && userParams == object [] && null inlineParams+                                    else if hasRequiredParams method && userParams == object [] && null inlineParams && isNothing inputFlags                                       then if cached then stale "help" else do                                         -- Render help from IR                                         case Map.lookup fullPath (irMethods ir) of@@ -1345,7 +1410,7 @@                                             -- Fallback: method not in IR, use basic info                                             liftIO $ TIO.putStrLn $ T.intercalate "." path <> " - " <> methodDescription method                                       -- IR-15: fire stderr deprecation warnings before real invocation.-                                      else fireDeprecationWarnings >> keepPlan (Just ir) >> invokeMethod sourceEnv method path params+                                      else fireDeprecationWarnings >> keepPlan (Just ir) >> invokeMethod sourceEnv inputFlags method path params   where     -- AUTH-8: what the _info probe at this address answered (view, auth)     currentInfo :: SynapseM (Maybe BackendInfo)@@ -1443,7 +1508,29 @@             Left err -> throwParse $ "Invalid --response JSON: " <> T.pack err             Right (resp :: Response Value) -> sendResponse requestId resp -    invokeMethod srcEnv method path params = do+    -- BIDIR-18: what an input call reads and how: the flag's name, the+    -- item's kind, the chunk, --progress, the source. No stream named is+    -- refused before anything is sent, naming the flag.+    inputCall dotted method schema inputFlags = do+      let names = methodInputNames method+          flag = In.inStream names+          kind = In.itemKind schema+      flags <- maybe (pure (In.InputFlags Nothing Nothing False)) pure inputFlags+      src <- case In.ifSource flags of+        Nothing -> throwParse $ dotted <> " takes a stream in: give it with --" <> flag+          <> " - (stdin) or --" <> flag <> " @FILE"+        Just v -> do+          cwd <- liftIO getCurrentDirectory+          home <- liftIO $ (Just <$> getHomeDirectory) `E.catch` \(_ :: E.SomeException) -> pure Nothing+          either throwParse pure (In.sourceOf flag cwd home v)+      case src of+        In.FromFile p -> do+          ok <- liftIO $ doesFileExist p+          if ok then pure () else throwParse ("--" <> flag <> " @" <> T.pack p <> ": no such file")+        In.FromStdin -> pure ()+      pure (flag, kind, maybe In.defaultChunk id (In.ifChunk flags), In.ifProgress flags, src)++    invokeMethod srcEnv inputFlags method path params = do       let namespacePath = init path  -- path without method name       let methodName' = last path       -- Set method path hint for template resolution@@ -1512,9 +1599,28 @@                 pure (Just (mergeUpgradeHeaders (seToken env') (seCookies env') (seHeaders env')))       -- SYNAPSE-39: a stream has no end to wait for — with -o, its items       -- go to FILE itself, each as it comes-      when (methodStreaming method) $ liftIO Output.streaming-      -- Use bidirectional streaming handler-      invokeStreamingWithBidir+      when (methodCardinality method == CardinalityMany) $ liftIO Output.streaming+      case methodInput method of+       -- BIDIR-18: a method with an input half: its stream from stdin or a+       -- file, under credit, never sent twice. While no credit has come+       -- (nothing read), an item may be held back to run the call again+       -- as any call's is; after, every item is printed as it is.+       Just schema -> do+        (_, kind, chunk, progress, src) <- inputCall (T.intercalate "." path) method schema inputFlags+        when (src == In.FromStdin && stdinTaken) $+          throwParse "a value was read from stdin (-), so the input stream cannot read it too; give the value as @FILE"+        let printer' untouched item = do+              fresh <- untouched+              if fresh then printer item else do+                case item of+                  ServiceData{} -> Session.notePrinted+                  _ -> pure ()+                printResult soJson soRaw rendererCfg' item+        invokeWithInput (Reconnect.callShape method) refreshAuth kind chunk progress src+          namespacePath methodName' params printer'+       Nothing ->+        -- Use bidirectional streaming handler+        invokeStreamingWithBidir         (Reconnect.callShape method)         refreshAuth         resumed@@ -1583,6 +1689,32 @@           _ -> throwParse "JSON-RPC must have 'method' field"         _ -> throwParse "JSON-RPC must be an object" +-- | BIDIR-18: what a method's input flags are called, given its+-- parameters (a parameter named @input@ keeps @--input@; the stream is+-- then @--input-stream@).+methodInputNames :: MethodSchema -> In.InputNames+methodInputNames m = In.inputNames $ case methodParams m of+  Just (Object o) | Just (Object props) <- KM.lookup "properties" o -> map K.toText (KM.keys props)+  _ -> []++-- | BIDIR-18: how a method's flag takes its value, from its schema — a+-- boolean only a boolean word, any other parameter the next argument, an+-- unknown flag the next unless it is a flag (as 'Parse.flagArity' reads+-- the IR; the input flags are taken out before the IR is built).+methodFlagArity :: MethodSchema -> Text -> In.FlagArity+methodFlagArity m key = case methodParams m of+  Just (Object o) | Just (Object props) <- KM.lookup "properties" o+                  , Just prop <- KM.lookup (K.fromText (T.takeWhile (/= '.') key)) props ->+      if isBoolean prop && not ("." `T.isInfixOf` key) then In.TakesBoolean else In.TakesNext+  _ -> In.TakesUnlessFlag+  where+    isBoolean = \case+      Object p -> case KM.lookup "type" p of+        Just (String "boolean") -> True+        Just (Array ts) -> String "boolean" `elem` ts && all (`elem` [String "boolean", String "null"]) ts+        _ -> False+      _ -> False+ -- | Build JSON object from key-value pairs buildParamsObject :: [(Text, Text)] -> Value buildParamsObject pairs = object@@ -1654,12 +1786,15 @@       , "                      --cacert FILE trusts a local CA too. Sessions are kept per"       , "                      URL, so _login and every call take the same options"       , ""-      , "A connection held between runs (SYNAPSE-28):"-      , "  the first call to a backend starts a holder that keeps the signed-in socket"-      , "  open ($XDG_RUNTIME_DIR/synapse/, one per address and agent); later calls cost"-      , "  one round trip. It exits after --hold-for (10m) with nothing to do; --no-hold"-      , "  dials a connection of the run's own. A call is sent from the schema cache"-      , "  (~/.cache/synapse/schemas) and checked against the server only when it fails"+      , "A connection held between runs (SYNAPSE-28, SYNAPSE-40):"+      , "  the first call starts the holder, one process for all your calls"+      , "  ($XDG_RUNTIME_DIR/synapse/holder-4.sock), which keeps a signed-in socket per"+      , "  address and agent; later calls cost one round trip. A socket whose server is"+      , "  gone is let go at once, one idle for --hold-for (10m) closed, past 256 the"+      , "  longest idle first; the holder exits when it has held nothing for --hold-for."+      , "  --no-hold dials a connection of the run's own; synapse _hold status says what"+      , "  is held. A call is sent from the schema cache (~/.cache/synapse/schemas-4) and"+      , "  checked against the server only when it fails"       , ""       , "Meta-commands:"       , ""@@ -1902,7 +2037,7 @@     -- SYNAPSE-39: under -o the -j line goes with FILE when the run fails,     -- so the refusal is said on stderr too     toFile <- Output.active-    if json then LBS.putStrLn (encode item) else pure ()+    if json then LBS.putStrLn (jsonLine item) else pure ()     if not json || toFile then do       ceiling' <- Session.currentCeiling       TIO.hPutStr stderr (renderRefusal ceiling' r)@@ -1913,10 +2048,18 @@     case item of       ServiceError _ _ err _ | json, toFile -> hPutStrLn stderr $ "Error: " <> T.unpack err       _ -> pure ()+    if itemFailed item then Session.noteFailed else pure ()     printResult' json raw cfg item +-- | Did this item say the call failed? An error item, or data whose content+-- is an event of @type: error@ (a service's in-band error).+itemFailed :: ServiceStreamItem -> Bool+itemFailed (ServiceError _ _ _ _) = True+itemFailed (ServiceData _ _ _ (Object o)) = KM.lookup "type" o == Just (String "error")+itemFailed _ = False+ printResult' :: Bool -> Bool -> RendererConfig -> ServiceStreamItem -> IO ()-printResult' True _ _ item = LBS.putStrLn $ encode item+printResult' True _ _ item = LBS.putStrLn $ jsonLine item printResult' _ True _ item = case item of   -- Raw mode: just output the content   ServiceData _ _ _ dat -> do
plexus-synapse.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.0 name:               plexus-synapse-version:            3.17.3+version:            4.0.0 synopsis:           Schema-driven CLI for Plexus RPC servers description:        Synapse is the command-line frontend for Plexus RPC. It discovers a                     server's methods at runtime and builds its commands, help text,@@ -14,6 +14,8 @@ category:           Network build-type:         Simple tested-with:        GHC == 9.8.4, GHC == 9.12.4+extra-doc-files:    README.md+                    CHANGELOG.md  flag build-examples   description: Build example programs (schema-discovery)@@ -28,6 +30,16 @@   default:     False   manual:      True +flag live-tests+  description: Build the suites that need a live service: cli-test,+               websocket-raw-test and ir-test (a substrate and registry on+               127.0.0.1:4444; cli-test also a synapse binary at a fixed+               dist-newstyle path) and bidir-integration-test (a backend+               named in --test-options). Opt-in, so a default `cabal test`+               needs nothing running. Enable with: cabal test -f live-tests+  default:     False+  manual:      True+ library   exposed-modules:     Synapse.Schema.Types@@ -85,11 +97,15 @@     Synapse.Auth     Synapse.Auth.Loopback     Synapse.Hold+    Synapse.Input     Synapse.SchemaStore+  other-modules:    Paths_plexus_synapse+  autogen-modules:  Paths_plexus_synapse   build-depends:     base >= 4.17 && < 5,-    -- SYNAPSE-28: setCallRouter, substrateRpcSub, unsubscribe-    plexus-protocol >= 0.5.5,+    -- SYNAPSE-28: setCallRouter, substrateRpcSub, unsubscribe; RETURNS-12:+    -- the plexus 0.7 schema and envelope+    plexus-protocol >= 0.6 && < 0.7,     aeson >= 2.0 && < 2.3,     aeson-pretty >= 0.8 && < 0.9,     text >= 2.0 && < 2.2,@@ -232,6 +248,8 @@   default-extensions:     OverloadedStrings   ghc-options:      -threaded+  if !flag(live-tests)+    buildable: False  test-suite ir-test   type:             exitcode-stdio-1.0@@ -252,6 +270,8 @@     LambdaCase     RecordWildCards   ghc-options:      -threaded+  if !flag(live-tests)+    buildable: False  test-suite schema-decode-test   type:             exitcode-stdio-1.0@@ -388,6 +408,8 @@   default-extensions:     OverloadedStrings   ghc-options:      -threaded+  if !flag(live-tests)+    buildable: False  test-suite bidir-test   type:             exitcode-stdio-1.0@@ -423,6 +445,8 @@     OverloadedStrings     RecordWildCards   ghc-options:      -threaded+  if !flag(live-tests)+    buildable: False  test-suite stream-tracker-test   type:             exitcode-stdio-1.0@@ -528,6 +552,33 @@     OverloadedStrings   ghc-options:      -threaded +test-suite ir3-test+  -- RETURNS-13: IR 3.0. The golden test reads RETURNS-7's conformance+  -- fixture by sibling path (../plexus-macros/fixtures/conformance/expected/)+  -- and compares the IR built from its schema.json with its ir.json;+  -- SYNAPSE_GOLDEN_ACCEPT=1 rewrites ir.json instead. Then one test per row+  -- of the ticket's Required behavior table.+  type:             exitcode-stdio-1.0+  main-is:          IR3Spec.hs+  build-depends:+    base >= 4.17 && < 5,+    plexus-protocol,+    plexus-synapse,+    aeson >= 2.2 && < 2.3,+    aeson-pretty >= 0.8 && < 0.9,+    aeson-qq >= 0.8 && < 0.9,+    bytestring >= 0.11 && < 0.13,+    containers >= 0.6 && < 0.8,+    filepath >= 1.4 && < 1.6,+    text >= 2.0 && < 2.2,+    hspec >= 2.10 && < 2.12+  hs-source-dirs:   test+  default-language: GHC2021+  default-extensions:+    OverloadedStrings+    LambdaCase+  ghc-options:      -threaded+ test-suite keychain-test   -- SELF-8 keychain tests. Opt-in via `-f build-keychain-tests` because   -- they shell out to macOS `security` and touch the real keychain.@@ -554,7 +605,7 @@   -- service in miniature (test/MockService.hs) on ports of their own.   type:             exitcode-stdio-1.0   main-is:          AuthSpec.hs-  other-modules:    MockService+  other-modules:    MockService, Plexus07   build-depends:     base >= 4.17 && < 5,     plexus-protocol,@@ -582,7 +633,7 @@   -- terminal and piped; a caller's view; the IR cache's hash check.   type:             exitcode-stdio-1.0   main-is:          FindSpec.hs-  other-modules:    MockService+  other-modules:    MockService, Plexus07   build-depends:     base >= 4.17 && < 5,     plexus-synapse,@@ -635,10 +686,14 @@   -- at the mock service), a stream abandoned is unsubscribed, the holder exits   -- idle; and the built synapse, end to end: cold fills the cache on one   -- connection, warm opens none and fetches nothing, --no-hold opens one,-  -- a renamed method falls back and answers.+  -- a renamed method falls back and answers. SYNAPSE-40: one holder process+  -- for the user (counted in /proc), a connection per address and agent,+  -- one whose server stopped let go within 10 s, the cap closing the longest+  -- idle first, and a loop against short-lived servers (SYNAPSE40_LOOP,+  -- 500) through one holder whose memory stays flat.   type:             exitcode-stdio-1.0   main-is:          HoldSpec.hs-  other-modules:    MockService+  other-modules:    MockService, Plexus07   build-tool-depends: plexus-synapse:synapse   build-depends:     base >= 4.17 && < 5,@@ -647,12 +702,14 @@     aeson >= 2.2 && < 2.3,     async >= 2.2 && < 2.3,     bytestring >= 0.11 && < 0.13,+    containers >= 0.6 && < 0.8,     directory >= 1.3 && < 1.4,     filepath >= 1.4 && < 1.6,     network >= 3.1 && < 3.3,     process >= 1.6 && < 1.7,     temporary >= 1.3 && < 1.4,     text >= 2.0 && < 2.2,+    unix >= 2.7 && < 2.9,     vector >= 0.12 && < 0.14,     websockets >= 0.13 && < 0.14,     hspec >= 2.10 && < 2.12@@ -662,6 +719,37 @@     OverloadedStrings   ghc-options:      -threaded +test-suite input-test+  -- BIDIR-18: a call with an input half — the built synapse against the+  -- mock service's stream activation: lines and NDJSON as items, a bad line+  -- named and never carried, stdin not read past credit (memory flat),+  -- bytes as binary frames, a disconnect said and never resent (69), the+  -- typed closes' exits (70, 65, 77), --input-stream, -n, help and _find.+  type:             exitcode-stdio-1.0+  main-is:          InputSpec.hs+  other-modules:    MockService, Plexus07+  build-tool-depends: plexus-synapse:synapse+  build-depends:+    base >= 4.17 && < 5,+    aeson >= 2.2 && < 2.3,+    bytestring >= 0.11 && < 0.13,+    containers >= 0.6 && < 0.8,+    directory >= 1.3 && < 1.4,+    filepath >= 1.4 && < 1.6,+    network >= 3.1 && < 3.3,+    process >= 1.6 && < 1.7,+    temporary >= 1.3 && < 1.4,+    text >= 2.0 && < 2.2,+    unix >= 2.7 && < 2.9,+    vector >= 0.12 && < 0.14,+    websockets >= 0.13 && < 0.14,+    hspec >= 2.10 && < 2.12+  hs-source-dirs:   test+  default-language: GHC2021+  default-extensions:+    OverloadedStrings+  ghc-options:      -threaded+ test-suite pipe-test   -- SYNAPSE-26: a closed stdout is not an outage — the built synapse, its   -- stdout a pipe whose reader closes early (… | head -1): a write sent@@ -670,12 +758,13 @@   -- resent, a write that went out exit 75).   type:             exitcode-stdio-1.0   main-is:          PipeSpec.hs-  other-modules:    MockService+  other-modules:    MockService, Plexus07   build-tool-depends: plexus-synapse:synapse   build-depends:     base >= 4.17 && < 5,     aeson >= 2.2 && < 2.3,     bytestring >= 0.11 && < 0.13,+    containers >= 0.6 && < 0.8,     directory >= 1.3 && < 1.4,     filepath >= 1.4 && < 1.6,     network >= 3.1 && < 3.3,@@ -700,12 +789,13 @@   -- nothing sent.   type:             exitcode-stdio-1.0   main-is:          OutputSpec.hs-  other-modules:    MockService+  other-modules:    MockService, Plexus07   build-tool-depends: plexus-synapse:synapse   build-depends:     base >= 4.17 && < 5,     aeson >= 2.2 && < 2.3,     bytestring >= 0.11 && < 0.13,+    containers >= 0.6 && < 0.8,     directory >= 1.3 && < 1.4,     filepath >= 1.4 && < 1.6,     network >= 3.1 && < 3.3,@@ -747,7 +837,7 @@   -- name refused at the handshake. Pending without openssl.   type:             exitcode-stdio-1.0   main-is:          TlsSpec.hs-  other-modules:    MockService+  other-modules:    MockService, Plexus07   build-depends:     base >= 4.17 && < 5,     plexus-protocol,@@ -755,6 +845,7 @@     aeson >= 2.2 && < 2.3,     base64-bytestring >= 1.2 && < 1.3,     bytestring >= 0.11 && < 0.13,+    containers >= 0.6 && < 0.8,     directory >= 1.3 && < 1.4,     filepath >= 1.4 && < 1.6,     katip >= 0.8 && < 0.9,@@ -778,7 +869,7 @@   -- 20 times from memory — median, p90, bytes allocated per build.   type:             exitcode-stdio-1.0   main-is:          IRBench.hs-  other-modules:    IRFixture+  other-modules:    IRFixture, Plexus07   build-depends:     base >= 4.17 && < 5,     plexus-synapse,@@ -803,7 +894,7 @@   -- bare object: exit 65.   type:             exitcode-stdio-1.0   main-is:          IRCoverageSpec.hs-  other-modules:    IRFixture, MockService+  other-modules:    IRFixture, MockService, Plexus07   build-tool-depends: plexus-synapse:synapse   build-depends:     base >= 4.17 && < 5,
src/Synapse/Algebra/Recursion.hs view
@@ -196,6 +196,10 @@                 -- SYNAPSE-28: a run sending from the cache asks the server                 -- for what the cache lacks; it never skips it                 Left err | cacheCannotAnswer err -> pure (Left err)+                -- RETURNS-13: a child whose schema builds no IR (two shapes+                -- under one title) fails the walk; it is never skipped as a+                -- fetch that failed+                Left err@(ValidationError _) -> pure (Left err)                 Left err -> do                   hPutStrLn stderr $ "[synapse] warning: skipping plugin (fetch failed): " ++ show err                   pure (Right Nothing)
src/Synapse/CLI/Help.hs view
@@ -89,6 +89,7 @@ import Synapse.IR.Types import Synapse.Algebra.Render (deprecationMarker, formatDeprecationLine, fitText) import Synapse.CLI.Parse (isScalarElement)+import qualified Synapse.Input as In  -- ============================================================================ -- Configuration@@ -168,6 +169,7 @@       , [""]       , usageLines       , optionsBlock+      , inputBlock       , exampleBlock       , requiresBlock       ]@@ -195,7 +197,39 @@     optional = [p | p <- callerParams, not (pdRequired p)]      usageLines =-      [ "Usage: " <> T.unwords ([invoke] ++ map usageFlag required ++ ["[OPTIONS]" | not (null optional)]) ]+      [ "Usage: " <> T.unwords ([invoke] ++ map usageFlag required+          ++ [ "--" <> In.inStream inNames <> " <-|@FILE>" | hasInput ]+          ++ ["[OPTIONS]" | not (null optional)]) ]++    -- BIDIR-18: a method with an input half takes a stream in+    inSchema = mpSchema >>= methodInput+    hasInput = isJust (mdInput md) || isJust inSchema+    inNames = In.inputNames (map pdName (mdParams md))+    inKind = case inSchema of+      Just sch -> In.itemKind sch+      Nothing -> case mdInput md of+        Just (RefPrimitive "string" (Just "binary")) -> In.ItemBytes+        Just (RefPrimitive "string" _) -> In.ItemString+        _ -> In.ItemJson+    inType = case (inKind, mdInput md) of+      (In.ItemBytes, _) -> "bytes"+      (_, Just t) -> flagType ir t+      (In.ItemString, Nothing) -> "string"+      (_, Nothing) -> "JSON"+    inputBlock+      | not hasInput = []+      | otherwise =+          [ "", "Input (a stream in, sent as the service grants credit):" ]+          ++ hangAt inCol ("  --" <> In.inStream inNames <> " <-|@FILE>")+               (wrapWords (max 20 (width - inCol)) $ "stdin (-) or a file (@FILE) of " <> inType <> " items: " <> case inKind of+                  In.ItemBytes -> "the bytes as they are, cut into --" <> In.inChunk inNames <> " bytes, each a binary frame"+                  In.ItemString -> "one line, one item"+                  In.ItemJson -> "one JSON value a line (NDJSON); a line that is not one is named and nothing carries it")+          ++ [ l | inKind == In.ItemBytes+                 , l <- hangAt inCol ("  [--" <> In.inChunk inNames <> " <bytes>]")+                          ["bytes a binary frame (default " <> T.pack (show In.defaultChunk) <> ")"] ]+          ++ hangAt inCol ("  [--" <> In.inProgress inNames <> "]") ["sent, acked and credit on stderr, once a second"]+    inCol = 2 + 24     usageFlag p = flagOf p <> " <" <> T.toUpper (pdName p) <> ">"      optionsBlock@@ -251,7 +285,8 @@     exampleLines i ex =       [ "" | i > 0 ]         ++ maybe [] (\n -> ["  # " <> n]) (exampleNote ex)-        ++ ["  " <> renderExample mpProgram mpTarget md (exampleParams ex)]+        ++ ["  " <> renderExample mpProgram mpTarget md (exampleParams ex)+              <> (if hasInput then " --" <> In.inStream inNames <> " -" else "")]      -- AUTH-8: in a caller's view, the grant and where it is held.     requiresBlock = case mpSchema of
src/Synapse/CLI/Parse.hs view
@@ -66,6 +66,8 @@   ( -- * Command line     parsePathAndParams   , pairFlags+  , FlagArity(..)+  , takeFlag      -- * Parsing   , parseParams
src/Synapse/Find.hs view
@@ -67,7 +67,8 @@ import Data.Char (isAlphaNum) import Data.List (sortOn, foldl') import qualified Data.Map.Strict as Map-import Data.Maybe (fromMaybe, listToMaybe, mapMaybe)+import Data.Maybe (fromMaybe, isJust, listToMaybe, mapMaybe)+import qualified Synapse.Input as In import qualified Data.Set as Set import Data.Text (Text) import qualified Data.Text as T@@ -515,7 +516,10 @@     row h = case enParam (hitEntry h) of       Nothing ->         let descW = max minDesc (width - col1)-        in [ T.stripEnd ("  " <> locCell h <> descCell h descW (methodText (enMethod (hitEntry h)))) ]+            m = enMethod (hitEntry h)+        in T.stripEnd ("  " <> locCell h <> descCell h descW (methodText m))+             -- BIDIR-18: a method with an input half says how its stream is given+             : [ T.replicate col1 " " <> inputLine frIR m | isJust (mdInput m) ]       Just p ->         let descW = max minDesc (width - col2)             label = flagLabel frIR p@@ -635,6 +639,23 @@     locW = maximum (0 : map (T.length . entryLocation) near)     desc e = fromMaybe "" (mdDescription (enMethod e)) +-- | BIDIR-18: what a method's input half is called and takes.+inputNamesOf :: MethodDef -> In.InputNames+inputNamesOf m = In.inputNames (map pdName (mdParams m))++-- | The item type of an input, as a flag shows a type (bytes, which the IR+-- does not type, as such).+inputType :: IR -> TypeRef -> Text+inputType ir = \case+  RefPrimitive "string" (Just "binary") -> "bytes"+  RefUnknown -> "any"+  t -> flagType ir t++-- | @takes a stream in: --input -|@FILE, Item items@+inputLine :: IR -> MethodDef -> Text+inputLine ir m = "takes a stream in: --" <> In.inStream (inputNamesOf m) <> " -|@FILE"+  <> maybe "" (\t -> ", " <> inputType ir t <> " items") (mdInput m)+ -- | One hit as JSON, for @-j@. hitJson :: IR -> Hit -> Value hitJson ir h = object $@@ -649,6 +670,8 @@          , "description" .= fromMaybe "" (pdDescription p)          ] ++ maybe [] (\vs -> ["values" .= vs]) (paramValuesOf ir p)        Nothing -> [ "description" .= fromMaybe "" (mdDescription m) ]+         ++ [ "input" .= object [ "flag" .= ("--" <> In.inStream (inputNamesOf m)), "item" .= inputType ir t ]+            | Just t <- [mdInput m] ]   ++   [ "group" .= (case hitGroup h of { Methods -> "methods"; Parameters -> "parameters"; Mentioned -> "mentioned" } :: Text)   , "score" .= (fromIntegral (round (hitScore h * 100) :: Int) / 100 :: Double)
src/Synapse/Hold.hs view
@@ -3,21 +3,27 @@ {-# LANGUAGE ScopedTypeVariables #-}  -- | SYNAPSE-28: a call costs one round trip and the server's time.+-- SYNAPSE-40: and one process holds every connection a user's runs keep. -- -- Every call synapse makes goes through plexus-protocol's -- 'ST.setCallRouter' hook to one of two places: ----- * __the holder__ — a process of synapse's own (@synapse _hold@), one per---   backend address and agent, in the manner of ssh's ControlMaster. It---   keeps the signed-in WebSocket open between runs and listens on a unix---   socket (@$XDG_RUNTIME_DIR/synapse/ADDRESS\@AGENT-VERSION.sock@, 0600,---   in a 0700 directory). A run hands it each request and reads the reply---   back, so a warm call costs local IPC and one round trip. The first run---   starts it; it exits after @--hold-for@ (10 minutes) with nothing to do.+-- * __the holder__ — a process of synapse's own (@synapse _hold@), __one+--   per user__ (and per 'holdProtocol'), in the manner of ssh's+--   ControlMaster. It keeps signed-in WebSockets open between runs, one per+--   address, agent and credential, and listens on a unix socket+--   (@$XDG_RUNTIME_DIR/synapse/holder-4.sock@, 0600, in a 0700 directory).+--   A run hands it each request, with the agent it calls as, and reads the+--   reply back, so a warm call costs local IPC and one round trip. The+--   first run starts it. A connection whose server has gone is let go at+--   once (within the second); one idle for @--hold-for@ (10 minutes) is+--   closed; past 'defaultCap' connections the one idle longest is closed+--   first; and the holder exits once it has held nothing and served nothing+--   for @--hold-for@. -- * __the multiplexer__ in this process ('Mux') — what the holder itself --   runs, and what a run uses with @--no-hold@ or when no holder can be---   had: one connection per address and credential, every call of the---   run on it (JSON-RPC carries many by id), never one per call.+--   had: one connection per address, agent and credential, every call of+--   the run on it (JSON-RPC carries many by id), never one per call. -- -- Either way a failure reads as it did before: a lost connection is -- 'LostConnection' (with whether the request had gone out), a failed dial@@ -27,23 +33,43 @@ -- changed (a session made again from the key) is a new connection in the -- holder, beside the old one until it is idle; the holder never signs in -- by itself.+--+-- SYNAPSE-28's rule that a call never rides another agent's connection+-- holds by the key: the agent is part of it, so two agents with the same+-- credential (none at all, say) still have a connection each.+--+-- Older synapse binaries (3.16.0 to 3.17.3) keep a holder per address and+-- agent at @ADDRESS\@AGENT-VERSION.sock@ in the same directory; nothing+-- here opens, removes or answers those, so they go on working beside it. module Synapse.Hold   ( -- * The multiplexer     Mux   , newMux+  , newMuxWith   , muxCall+  , muxCallAs+  , muxCallDuplex   , muxActive   , muxConnections+  , Held(..)+  , muxHolding      -- * The holder   , HoldOpts(..)+  , holdOpts   , runHolder-  , socketPathFor+  , holdProtocol+  , holderSocketPath   , defaultHoldFor+  , defaultCap      -- * Through the holder   , holderCall+  , holderCallAs+  , holderCallDuplex+  , holderStatus   , HolderGone(..)+  , olderThan      -- * The router   , RouterOpts(..)@@ -51,12 +77,12 @@   , routedCall   ) where -import Control.Concurrent (forkIO, threadDelay, myThreadId)-import Control.Concurrent.Async (async, cancel, waitCatch, withAsync)+import Control.Concurrent (forkIO, forkIOWithUnmask, threadDelay)+import Control.Concurrent.Async (async, asyncWithUnmask, cancel, wait, waitCatch, withAsync) import Control.Concurrent.MVar import Control.Concurrent.STM import Control.Exception-import Control.Monad (forever, unless, void, when)+import Control.Monad (forM, forM_, forever, unless, void, when) import Data.Aeson hiding (Key) import qualified Data.Aeson.KeyMap as KM import qualified Data.ByteString as BS@@ -64,68 +90,107 @@ import qualified Data.ByteString.Lazy as LBS import qualified Data.CaseInsensitive as CI import Data.IORef+import Data.List (sortOn) import qualified Data.Map.Strict as Map-import Data.Maybe (isJust)+import Data.Maybe (fromMaybe, isJust) import Data.Text (Text) import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import Text.Read (readMaybe)+import Foreign.C.Error (Errno(..), eAGAIN, throwErrnoIfMinus1RetryMayBlock_) import Foreign.C.Types (CInt(..)) import GHC.Clock (getMonotonicTime)+import GHC.IO.Exception (IOException(..)) import qualified Network.Socket as N import qualified Network.Socket.ByteString as NB import qualified Streaming.Prelude as S-import System.Directory (createDirectoryIfMissing, doesDirectoryExist, getTemporaryDirectory, removeFile)-import System.Environment (getExecutablePath, lookupEnv)+import System.Directory (createDirectoryIfMissing, doesDirectoryExist, getTemporaryDirectory, makeAbsolute, removeFile)+import System.Environment (getEnvironment, getExecutablePath, lookupEnv) import System.Exit (exitSuccess) import System.FilePath ((</>), takeDirectory) import System.IO (hClose, IOMode(..), openFile) import System.Posix.Files (setFileMode, getFileStatus, fileID, fileOwner, fileMode) import Data.Bits ((.&.)) import System.Posix.IO (closeFd, createFile)+import System.Posix.Process (getProcessID) import System.Posix.Signals (installHandler, sigTERM, sigHUP, Handler(..)) import System.Posix.User (getRealUserID)-import System.Process (CreateProcess(..), StdStream(..), createProcess, proc)+import System.Process (CreateProcess(..), StdStream(..), createProcess, getProcessExitCode, proc) import System.Timeout (timeout)-import Foreign.C.Error (throwErrnoIfMinus1RetryMayBlock_) -import Plexus.Client (SubstrateConfig(..), SubstrateConnection, LostConnection(..), TlsOptions(..), connect, disconnect, connectionLost, substrateRpcSub, unsubscribe)+import Plexus.Client (SubstrateConfig(..), SubstrateConnection, LostConnection(..), TlsOptions(..), connect, disconnect, connectionLost, substrateRpcDuplex, unsubscribe)+import Plexus.Input (InputHalf(..), InputPort(..), inputSignal, inputSignalValue) import qualified Plexus.Transport as ST+import qualified Synapse.Input as In import Plexus.Types (PlexusStreamItem(..))  -- ============================================================================ -- The multiplexer -- ============================================================================ --- | Where and as whom: a config with its backend left out (the backend--- names methods; it does not change the connection).-type ConnKey = SubstrateConfig+-- | Where, as whom and with what: the agent the call is made as, and the+-- config with its backend left out (the backend names methods; it does not+-- change the connection) — its address and the credential its headers+-- carry. SYNAPSE-40: the agent is in the key, so a call as one agent never+-- rides another agent's connection, however alike their credentials.+data ConnKey = ConnKey+  { ckAgent  :: Text+  , ckConfig :: SubstrateConfig+  }+  deriving (Eq, Ord) -keyOf :: SubstrateConfig -> ConnKey-keyOf c = c { substrateBackend = "" }+keyOf :: Text -> SubstrateConfig -> ConnKey+keyOf agent c = ConnKey agent c { substrateBackend = "" }  -- | One connection and the calls on it. data Upstream = Upstream   { upConn     :: SubstrateConnection   , upInflight :: TVar Int-  , upLastUsed :: IORef Double+  , upClosing  :: TVar Bool       -- ^ SYNAPSE-40: being let go; no call picks it+  , upLastUsed :: TVar Double     -- ^ when a call last took or gave back a slot on it+  , upHoldFor  :: IORef Double    -- ^ SYNAPSE-40: how long it may sit idle — the --hold-for of the last call on it   , upSerial   :: Int   } --- | Connections by address and credential, shared by every call.+-- | Connections by agent, address and credential, shared by every call. data Mux = Mux   { muxUps     :: MVar (Map.Map ConnKey [Upstream])-  , muxDialing :: MVar (Map.Map ConnKey (MVar ()))+  , muxDialing :: MVar (Map.Map ConnKey (MVar (), Int))+    -- ^ one dial at a time per key; an entry lives while someone dials or waits   , muxBusy    :: TVar Int        -- ^ calls under way   , muxLast    :: IORef Double    -- ^ when the last call ended (or began)-  , muxSerial  :: IORef Int       -- ^ connections opened (for tests, and the holder's log)+  , muxSerial  :: IORef Int       -- ^ connections opened (for tests, and the holder's status)   , muxShared  :: MVar (Map.Map (ConnKey, Text, BS.ByteString) (MVar (Either SomeException [PlexusStreamItem])))     -- ^ schema and _info reads under way, each asked once however many wait+  , muxCap     :: Int+    -- ^ SYNAPSE-40: the most connections kept at once (0: no cap)+  , muxIdle    :: Double+    -- ^ how long a connection may sit idle when its call does not say   } +-- | A multiplexer with the default cap. newMux :: IO Mux-newMux = do+newMux = newMuxWith defaultCap defaultHoldFor++-- | A multiplexer keeping at most this many connections (0: no cap), each+-- idle for at most the seconds unless its call says otherwise.+newMuxWith :: Int -> Double -> IO Mux+newMuxWith cap idle = do   now <- getMonotonicTime-  Mux <$> newMVar Map.empty <*> newMVar Map.empty <*> newTVarIO 0 <*> newIORef now <*> newIORef 0 <*> newMVar Map.empty+  Mux <$> newMVar Map.empty <*> newMVar Map.empty <*> newTVarIO 0 <*> newIORef now <*> newIORef 0+      <*> newMVar Map.empty <*> pure (max 0 cap) <*> pure idle +-- | SYNAPSE-40: the most connections the holder keeps. A live connection+-- costs it about 96 kB (measured: 403 held to live services, 17.2 MB with+-- 3 → 55.8 MB, hold-test's "what its cap is set by"), so at 256 the holder+-- stays under about 45 MB — three of SYNAPSE-28's holders, where a loop+-- left 1,634 of them — while 256 is twenty times the 13 live connections+-- this machine's agents held when SYNAPSE-40 was filed. Past it, the+-- connection idle longest is closed first; one carrying a call never is,+-- so a burst of open calls may pass the cap until they end.+defaultCap :: Int+defaultCap = 256+ -- | Calls under way, and when the last one ended. muxActive :: Mux -> IO (Int, Double) muxActive m = (,) <$> readTVarIO (muxBusy m) <*> readIORef (muxLast m)@@ -134,21 +199,58 @@ muxConnections :: Mux -> IO Int muxConnections = readIORef . muxSerial +-- | SYNAPSE-40: a connection held, as the holder's status shows it — its+-- address and agent, never its credential.+data Held = Held+  { heldConfig :: SubstrateConfig   -- ^ with its headers left out+  , heldAgent  :: Text+  , heldCalls  :: Int+  , heldIdle   :: Double            -- ^ seconds since a call last ended on it+  }++-- | The connections held now.+muxHolding :: Mux -> IO [Held]+muxHolding mux = do+  now <- getMonotonicTime+  ups <- readMVar (muxUps mux)+  forM [ (k, u) | (k, us) <- Map.toList ups, u <- us ] $ \(k, u) -> do+    n <- readTVarIO (upInflight u)+    l <- readTVarIO (upLastUsed u)+    pure Held { heldConfig = (ckConfig k) { substrateHeaders = [] }, heldAgent = ckAgent k+              , heldCalls = n, heldIdle = max 0 (now - l) }+ -- | Calls one connection carries before another is opened beside it -- (menger refuses past 256 open calls on one connection, ACCESS-29). perConnection :: Int perConnection = 200 --- | One call on the shared connection for its address and credential —--- dialed when there is none (one dial at a time per key), dropped when it--- is found dead. A call this side abandons (its thread cancelled, or its--- consumer failing) is unsubscribed, so a held connection never fills up--- with streams no one reads.+-- | One call on the shared connection for its address and credential, as+-- no agent in particular (a run's own multiplexer: the run is one agent). muxCall :: Mux -> SubstrateConfig -> ST.CallRoute-muxCall mux cfg onSub method params onItem-  | shareable method params = shared mux cfg onSub method params onItem-  | otherwise = muxCall' mux cfg onSub method params onItem+muxCall mux cfg = muxCallAs mux "" Nothing cfg Nothing +-- | BIDIR-18: 'muxCall' for a call that may carry an input half.+muxCallDuplex :: Mux -> SubstrateConfig -> ST.DuplexRoute+muxCallDuplex mux = muxCallAs mux "" Nothing++-- | One call, as the agent, on the shared connection for its address and+-- credential — dialed when there is none (one dial at a time per key),+-- dropped when it is found dead — kept afterwards for the call's+-- @--hold-for@ (else the multiplexer's). A call this side abandons (its+-- thread cancelled, or its consumer failing) is unsubscribed, so a held+-- connection never fills up with streams no one reads.+--+-- BIDIR-18: a call with an input half carries it on the same connection+-- (spec/10: only the socket that holds the call may send its input), and+-- is never shared.+muxCallAs :: Mux -> Text -> Maybe Double -> SubstrateConfig -> ST.DuplexRoute+muxCallAs mux agent hf cfg half onSub method params onItem+  | Nothing <- half, shareable method params = shared mux key idle onSub method params onItem+  | otherwise = muxCall' mux key idle half onSub method params onItem+  where+    key = keyOf agent cfg+    idle = fromMaybe (muxIdle mux) hf+ -- | A read every run asks the same of, and asks first: a schema -- (@<backend>.call@ of @….schema@) or @_info@. Twenty runs starting at once -- (a cold cache) wait on one answer — menger's @tickets@ schema is 2.6 MB —@@ -159,30 +261,33 @@   _ -> False  -- | The read asked once: the first asks it, the rest are given what it was--- answered (or how it failed), each told it was taken first.-shared :: Mux -> SubstrateConfig -> ST.CallRoute-shared mux cfg onSub method params onItem = do-  let key = (keyOf cfg, method, LBS.toStrict (encode params))+-- answered (or how it failed), each told it was taken first. The first+-- asker's answer is always given to the rest, however its own call ends+-- (its run going away included), so none of them waits for ever.+shared :: Mux -> ConnKey -> Double -> ST.CallRoute+shared mux ck idle onSub method params onItem = mask $ \restore -> do+  let key = (ck, method, LBS.toStrict (encode params))   mine <- newEmptyMVar   existing <- modifyMVar (muxShared mux) $ \m -> case Map.lookup key m of     Just w -> pure (m, Just w)     Nothing -> pure (Map.insert key mine m, Nothing)   case existing of-    Just waiting -> readMVar waiting >>= \case+    Just waiting -> restore $ readMVar waiting >>= \case       Right items -> onSub >> mapM_ onItem items       -- the first run went away (its call cancelled): ask for this one-      Left e | Just (_ :: SomeAsyncException) <- fromException e -> muxCall' mux cfg onSub method params onItem+      Left e | Just (_ :: SomeAsyncException) <- fromException e -> muxCall' mux ck idle Nothing onSub method params onItem       Left e -> throwIO e     Nothing -> do       acc <- newIORef []-      r <- try (muxCall' mux cfg onSub method params (\i -> modifyIORef' acc (i :) >> onItem i))-      items <- reverse <$> readIORef acc-      modifyMVar_ (muxShared mux) (pure . Map.delete key)-      putMVar mine (either Left (const (Right items)) r)+      r <- try (restore (muxCall' mux ck idle Nothing onSub method params (\i -> modifyIORef' acc (i :) >> onItem i)))+      uninterruptibleMask_ $ do+        items <- reverse <$> readIORef acc+        modifyMVar_ (muxShared mux) (pure . Map.delete key)+        putMVar mine (either Left (const (Right items)) r)       either throwIO pure r -muxCall' :: Mux -> SubstrateConfig -> ST.CallRoute-muxCall' mux cfg onSub method params onItem = do+muxCall' :: Mux -> ConnKey -> Double -> ST.DuplexRoute+muxCall' mux key idle half onSub method params onItem = do   now <- getMonotonicTime   writeIORef (muxLast mux) now   bracket_ (atomically (modifyTVar' (muxBusy mux) (+ 1)))@@ -190,10 +295,10 @@                getMonotonicTime >>= writeIORef (muxLast mux)) $     attempt True   where-    key = keyOf cfg+    -- the slot taken on a connection is given back however the call ends+    -- (bracket: never a connection counted busy for ever, so never idle)     attempt again = do-      up <- acquire mux key cfg-      r <- try (onUpstream up) `finally` release up+      (up, r) <- bracket (acquire mux key idle) release (\up -> (,) up <$> try (onUpstream up))       case r of         -- the connection had died idle: nothing was sent, so once more on a         -- fresh one (plexus-protocol's freshIfUnsent, here)@@ -213,7 +318,7 @@               StreamError{} -> writeIORef finished True               _ -> pure ()             onItem i-      S.mapM_ deliver (substrateRpcSub (upConn up) onS method params)+      S.mapM_ deliver (substrateRpcDuplex (upConn up) half onS method params)         `onException` do           s <- readIORef sub           f <- readIORef finished@@ -222,105 +327,231 @@                                   `catch` \(_ :: SomeException) -> pure ()             _ -> pure () --- | A live connection for the key with room on it, or a new one.-acquire :: Mux -> ConnKey -> SubstrateConfig -> IO Upstream-acquire mux key cfg = do+-- | A live connection for the key with room on it, or a new one — room+-- made for it first when the multiplexer holds its cap.+acquire :: Mux -> ConnKey -> Double -> IO Upstream+acquire mux key idle = do   picked <- pick   case picked of     Just up -> pure up-    Nothing -> do-      lock <- modifyMVar (muxDialing mux) $ \m -> case Map.lookup key m of-        Just l -> pure (m, l)-        Nothing -> do-          l <- newMVar ()-          pure (Map.insert key l m, l)-      withMVar lock $ \() -> do-        again <- pick-        case again of-          Just up -> pure up-          Nothing -> do-            conn <- connect cfg { substrateBackend = "" }+    Nothing -> withDialLock mux key $ do+      again <- pick+      case again of+        Just up -> pure up+        Nothing -> mask $ \restore -> do+          when (muxCap mux > 0) $ restore (trimTo mux (muxCap mux - 1))+          conn <- restore (dialKept (ckConfig key))+          -- held from the moment it is made: nothing between the dial and+          -- the map may leave it open and held by no one+          uninterruptibleMask_ $ do             n <- atomicModifyIORef' (muxSerial mux) (\k -> (k + 1, k + 1))             inflight <- newTVarIO 1-            used <- newIORef =<< getMonotonicTime-            let up = Upstream conn inflight used n+            closing <- newTVarIO False+            used <- newTVarIO =<< getMonotonicTime+            held <- newIORef idle+            let up = Upstream conn inflight closing used held n             modifyMVar_ (muxUps mux) (pure . Map.insertWith (flip (++)) key [up])             pure up   where     pick = do       ups <- Map.findWithDefault [] key <$> readMVar (muxUps mux)-      alive <- filterAlive ups-      when (length alive /= length ups) $-        modifyMVar_ (muxUps mux) (pure . Map.adjust (const alive) key)       let try' [] = pure Nothing           try' (u : us) = do-            ok <- atomically $ do+            gone <- connectionLost (upConn u)+            when (isJust gone) $ drop' mux key u+            now <- getMonotonicTime+            ok <- if isJust gone then pure False else atomically $ do+              closing <- readTVar (upClosing u)               n <- readTVar (upInflight u)-              if n < perConnection then True <$ writeTVar (upInflight u) (n + 1) else pure False+              if not closing && n < perConnection+                then True <$ (writeTVar (upInflight u) (n + 1) >> writeTVar (upLastUsed u) now)+                else pure False+            when ok $ writeIORef (upHoldFor u) idle             if ok then pure (Just u) else try' us-      try' alive-    filterAlive = fmap concat . mapM (\u -> do-      g <- connectionLost (upConn u)-      pure [u | not (isJust g)])+      try' ups +-- | Dial in a thread of its own. A call cancelled while its connection is+-- being made (its run went away) leaves the dial to finish and the+-- connection closed — never open, answering pings, and held by no one.+dialKept :: SubstrateConfig -> IO SubstrateConnection+dialKept cfg = do+  -- unmasked: the dial's own threads (reader, keepalive) start as any would+  d <- asyncWithUnmask (\unmask -> unmask (connect cfg))+  wait d `onException` void (forkIO (waitCatch d >>= either (const (pure ()))+    (\c -> disconnect c `catch` \(_ :: SomeException) -> pure ())))++-- | One dial at a time for the key; the lock is forgotten once no one+-- holds or waits for it (a holder serving a thousand throwaway ports keeps+-- no trace of them).+withDialLock :: Mux -> ConnKey -> IO a -> IO a+withDialLock mux key act = do+  lock <- modifyMVar (muxDialing mux) $ \m -> case Map.lookup key m of+    Just (l, n) -> pure (Map.insert key (l, n + 1) m, l)+    Nothing -> do+      l <- newMVar ()+      pure (Map.insert key (l, 1) m, l)+  withMVar lock (const act) `finally`+    modifyMVar_ (muxDialing mux)+      (pure . Map.update (\(l, n) -> if n <= 1 then Nothing else Just (l, n - 1)) key)++-- | Give back a call's slot, stamped in the same transaction: no sweep+-- sees it free with the time it was taken. release :: Upstream -> IO () release up = do-  atomically (modifyTVar' (upInflight up) (subtract 1))-  getMonotonicTime >>= writeIORef (upLastUsed up)+  now <- getMonotonicTime+  atomically $ do+    writeTVar (upLastUsed up) now+    modifyTVar' (upInflight up) (subtract 1) +-- | Let the connection go: no call picks it from now on, it leaves the map+-- (and its key, when it was the last), and it is closed off this thread (a+-- close never holds the holder up). drop' :: Mux -> ConnKey -> Upstream -> IO ()-drop' mux key up = do-  modifyMVar_ (muxUps mux) (pure . Map.adjust (filter ((/= upSerial up) . upSerial)) key)-  disconnect (upConn up) `catch` \(_ :: SomeException) -> pure ()+drop' mux key up = uninterruptibleMask_ $ do+  -- whole or not at all: a connection marked closing but left in the map+  -- would be picked by no call, closed by no sweep, and keep the holder up+  atomically (writeTVar (upClosing up) True)+  modifyMVar_ (muxUps mux) $ pure . Map.update+    (\us -> case filter ((/= upSerial up) . upSerial) us of { [] -> Nothing; rest -> Just rest }) key+  void $ forkIOWithUnmask $ \unmask -> unmask (disconnect (upConn up)) `catch` \(_ :: SomeException) -> pure () --- | Close the connections nothing uses: idle ones for an address whose--- credential has since changed (a session made again), and any idle for--- longer than @maxIdle@.-sweep :: Mux -> Double -> IO ()-sweep mux maxIdle = do+-- | Claim a connection nothing is on (and, by its last use, idle as the+-- test says), to close it: a call that picks it at the same moment wins,+-- and it is kept.+claimIdle :: (Double -> Bool) -> Upstream -> IO Bool+claimIdle idleEnough up = atomically $ do+  n <- readTVar (upInflight up)+  closing <- readTVar (upClosing up)+  l <- readTVar (upLastUsed up)+  if n == 0 && not closing && idleEnough l then True <$ writeTVar (upClosing up) True else pure False++-- | Close it if it is free and idle as the test says.+closeIfIdle :: Mux -> ConnKey -> Upstream -> (Double -> Bool) -> IO ()+closeIfIdle mux k u idleEnough = uninterruptibleMask_ $ do+  ok <- claimIdle idleEnough u+  when ok $ drop' mux k u++-- | Close idle connections, the one idle longest first, until at most+-- @limit@ are held (a connection carrying a call is never closed for it).+trimTo :: Mux -> Int -> IO ()+trimTo mux limit = do+  ups <- readMVar (muxUps mux)+  let held = [ (k, u) | (k, us) <- Map.toList ups, u <- us ]+      excess = length held - max 0 limit+  when (excess > 0) $ do+    stamped <- forM held $ \(k, u) -> (\l -> (l, k, u)) <$> readTVarIO (upLastUsed u)+    let close' 0 _ = pure ()+        close' _ [] = pure ()+        close' n ((_, k, u) : rest) = do+          ok <- uninterruptibleMask_ $ do+            c <- claimIdle (const True) u+            c <$ when c (drop' mux k u)+          if ok then close' (n - 1 :: Int) rest else close' n rest+    close' excess (sortOn (\(l, _, _) -> l) stamped)++-- | Let go what is not worth holding: at once, a connection whose server+-- has gone (closed, reset, or silent past the keepalive: plexus-protocol+-- has filled its 'connectionLost'); when idle, one superseded by a newer+-- credential of the same agent at the same address (a session made again),+-- and one idle for longer than its @--hold-for@ (or than @override@, a+-- draining holder's 0); and past the cap, the idle longest first.+sweep :: Mux -> Maybe Double -> IO ()+sweep mux override = do   now <- getMonotonicTime   ups <- readMVar (muxUps mux)-  -- per address, the credential used last is kept-  stamped <- mapM (\(k, us) -> do-                     ls <- mapM (readIORef . upLastUsed) us-                     pure (k, us, if null ls then 0 else maximum ls)) (Map.toList ups)-  let place k = (substrateHost k, substratePort k, substratePath k, substrateTls k)-      latest = Map.fromListWith max [(place k, t) | (k, _, t) <- stamped]-  mapM_ (\(k, us, t) -> mapM_ (\u -> do-           n <- readTVarIO (upInflight u)-           l <- readIORef (upLastUsed u)-           let superseded = Map.lookup (place k) latest > Just t && now - l > 5-           when (n == 0 && (superseded || now - l > maxIdle)) $ drop' mux k u) us) stamped+  facts <- forM [ (k, u) | (k, us) <- Map.toList ups, u <- us ] $ \(k, u) -> do+    gone <- connectionLost (upConn u)+    l <- readTVarIO (upLastUsed u)+    hf <- maybe (readIORef (upHoldFor u)) pure override+    pure (k, u, isJust gone, l, hf)+  -- per agent and address, the credential used last is kept+  let place k = let c = ckConfig k+                in (ckAgent k, substrateHost c, substratePort c, substratePath c, substrateTls c)+      lastOfKey = Map.fromListWith max [ (k, l) | (k, _, _, l, _) <- facts ]+      latest = Map.fromListWith max [ (place k, l) | (k, l) <- Map.toList lastOfKey ]+  forM_ facts $ \(k, u, dead, _, hf) ->+    if dead then drop' mux k u else do+      let superseded = Map.lookup (place k) latest > Map.lookup k lastOfKey+      -- decided on the last use read with the claim, so a call that just+      -- ended is never taken for one idle since before it began+      closeIfIdle mux k u (\l -> (superseded && now - l > 5) || now - l > hf)+  when (muxCap mux > 0) $ trimTo mux (muxCap mux) +-- | How many connections are held.+heldCount :: Mux -> IO Int+heldCount mux = sum . map length . Map.elems <$> readMVar (muxUps mux)+ -- ============================================================================ -- The wire between a run and its holder -- ============================================================================ -- -- One request per unix connection, a line of JSON each way: -----   run → holder   {"host","port","path","tls","ca","headers","method","params"}---   holder → run   {"t":"taken"}            the request is read; it goes out now+--   run → holder   {"agent","hold_for","host","port","path","tls","ca","headers","method","params"}+--   holder → run   {"t":"taken","v"}        the request is read; it goes out now+--                                           (v: the holder's synapse version) --                  {"t":"sub"}              the server took the call --                  {"t":"item","i":ITEM}    each item, as the server sent it --                  {"t":"end"}              the call ended --                  {"t":"lost","why","sent"} the connection went (LostConnection) --                  {"t":"error","why"}      any other failure (a refused dial) --+--   BIDIR-18, a call with an input half (the request has "input":true):+--   holder → run   {"t":"sig","s":SIGNAL}   credit, ack or input_closed, as the+--                                           server sent it (after "sub")+--   run → holder   {"in":{"seq","items"}}   one <ns>.input frame+--                  b SEQ LEN                one binary frame: this line (not+--                                           JSON: a voice call sends 50 a second),+--                                           then LEN bytes of payload as they are+--                  {"end":{"seq"}}          <ns>.end+--   The holder builds each frame with the call's subscription and sends it+--   on the call's own connection; the run numbers them and keeps the credit.+--+--   run → holder   {"status":true}+--   holder → run   {"t":"status","protocol","version","pid","cap","hold_for",+--                   "clients","calls","held":[{"host","port","path","tls",+--                   "agent","calls","idle"}, …]}   never a credential+--+--   run → holder   {"drain":true}           (sent holding the socket's lock)+--   holder → run   {"t":"draining"}         it has let go of the socket; it+--                                           finishes what it carries, closes+--                                           what is idle, and exits+-- -- A run that goes away mid-call closes its socket; the holder cancels the -- call (unsubscribing it). -encodeRequest :: SubstrateConfig -> Text -> Value -> Value-encodeRequest c method params = object-  [ "host" .= substrateHost c, "port" .= substratePort c, "path" .= substratePath c+-- | SYNAPSE-40: the holder's protocol — the wire above and what the holder+-- does with a request. It names the socket, so a run only ever talks to a+-- holder that reads it as it means it; bump it with any change to either+-- (the relay of items included: a holder built against another+-- plexus-protocol relays its 'PlexusStreamItem'). 1 was SYNAPSE-28's holder+-- per address and agent (@ADDRESS\@AGENT-VERSION.sock@, synapse 3.16.0 to+-- 3.17.3); 2 is one holder per user, the agent in each request (3.17.4);+-- 3 is 2 relaying plexus 0.8's items (RETURNS-36, synapse 4.0: the typed+-- error and progress), so a 4.0 holder never serves a 3.x run nor the+-- reverse; 4 is 3 carrying a call's input half (BIDIR-18: "input", "sig",+-- "in", "bin", "end"), so a run with input never meets a holder that+-- would drop it.+holdProtocol :: Int+holdProtocol = 4++encodeRequest :: Text -> Maybe Double -> Bool -> SubstrateConfig -> Text -> Value -> Value+encodeRequest agent holdFor input c method params = object $+  [ "hold_for" .= h | Just h <- [holdFor] ] +++  [ "input" .= True | input ] +++  [ "agent" .= agent+  , "host" .= substrateHost c, "port" .= substratePort c, "path" .= substratePath c   , "tls" .= isJust (substrateTls c)   , "ca" .= maybe [] tlsCaFiles (substrateTls c)   , "headers" .= [ [latin1 (CI.original k), latin1 v] | (k, v) <- substrateHeaders c ]   , "method" .= method, "params" .= params ]   where latin1 = T.pack . BS8.unpack -decodeRequest :: Value -> Maybe (SubstrateConfig, Text, Value)+decodeRequest :: Value -> Maybe (Text, Maybe Double, Bool, SubstrateConfig, Text, Value) decodeRequest = \case   Object o -> do+    let agent = case KM.lookup "agent" o of { Just (String a) -> a; _ -> "" }+        holdFor = case KM.lookup "hold_for" o of { Just (Number n) | n > 0 -> Just (realToFrac n); _ -> Nothing }     String host <- KM.lookup "host" o     port <- num =<< KM.lookup "port" o     String path <- KM.lookup "path" o@@ -331,7 +562,8 @@     let params = maybe Null id (KM.lookup "params" o)         bytes = BS8.pack . T.unpack     headers <- mapM (\case { [k, v] -> Just (CI.mk (bytes k), bytes v); _ -> Nothing }) (hs :: [[Text]])-    pure ( SubstrateConfig+    pure ( agent, holdFor, KM.lookup "input" o == Just (Bool True)+         , SubstrateConfig              { substrateHost = T.unpack host, substratePort = port, substratePath = T.unpack path              , substrateBackend = "", substrateHeaders = headers              , substrateTls = if tls then Just (TlsOptions ca) else Nothing }@@ -345,6 +577,14 @@       Success a -> Just a       Error _ -> Nothing +isStatusRequest, isDrainRequest :: Value -> Bool+isStatusRequest = \case+  Object o -> KM.lookup "status" o == Just (Bool True)+  _ -> False+isDrainRequest = \case+  Object o -> KM.lookup "drain" o == Just (Bool True)+  _ -> False+ -- | Lines from a socket. data LineReader = LineReader N.Socket (IORef BS.ByteString) @@ -367,6 +607,30 @@             then pure Nothing             else writeIORef buf (b <> chunk) >> go +-- | BIDIR-18: exactly @n@ bytes after the line just read (a binary+-- frame's payload), or Nothing when the socket ends first.+readExact :: LineReader -> Int -> IO (Maybe BS.ByteString)+readExact (LineReader s buf) n = do+  b <- readIORef buf+  if BS.length b >= n+    then do+      writeIORef buf (BS.drop n b)+      pure (Just (BS.take n b))+    else do+      writeIORef buf BS.empty+      -- the pieces kept apart and joined once: a large payload is copied+      -- once, not again with every piece+      go [b] (BS.length b)+  where+    go acc have+      | have >= n = do+          let whole = BS.concat (reverse acc)+          writeIORef buf (BS.drop n whole)+          pure (Just (BS.take n whole))+      | otherwise = do+          chunk <- NB.recv s (min (n - have) (4 * 1024 * 1024) `max` 65536) `catch` \(_ :: IOException) -> pure BS.empty+          if BS.null chunk then pure Nothing else go (chunk : acc) (have + BS.length chunk)+ sendLine :: N.Socket -> Value -> IO () sendLine s v = NB.sendAll s (LBS.toStrict (encode v) <> "\n") @@ -376,96 +640,209 @@  data HoldOpts = HoldOpts   { hoSocket  :: FilePath-  , hoHoldFor :: Double   -- ^ seconds with nothing to do before it exits+  , hoHoldFor :: Double   -- ^ seconds holding nothing, with nothing to do, before it exits+  , hoCap     :: Int      -- ^ the most connections kept at once (0: no cap)+  , hoVersion :: Text     -- ^ the synapse it is (its status says so)   } +-- | A holder at the socket, idle for the seconds, with the default cap.+holdOpts :: FilePath -> Double -> HoldOpts+holdOpts sock secs = HoldOpts { hoSocket = sock, hoHoldFor = secs, hoCap = defaultCap, hoVersion = "" }+ -- | Ten minutes. defaultHoldFor :: Double defaultHoldFor = 600 --- | Serve the socket until idle for 'hoHoldFor', then remove it and exit.+-- | Serve the socket until it has held nothing, with no run on it, for+-- 'hoHoldFor'; then remove it and exit. SIGTERM / SIGHUP: stop taking+-- runs at once, give the calls under way ten seconds to end, and go (a+-- stream still open then is told its connection was lost, and resumes as+-- SYNAPSE-20 says). Told to drain (a newer synapse is taking its place):+-- let go of the socket at once, close what is idle, finish what is open,+-- and go. runHolder :: HoldOpts -> IO () runHolder HoldOpts{..} = do-  mux <- newMux+  -- a holder that started slowly may find another serving the socket by+  -- now: that one stays, this one goes+  already <- answers hoSocket+  when already exitSuccess+  mux <- newMuxWith hoCap hoHoldFor   clients <- newTVarIO (0 :: Int)   lastSeen <- newIORef =<< getMonotonicTime+  draining <- newIORef False   lsock <- N.socket N.AF_UNIX N.Stream N.defaultProtocol   privateDir (takeDirectory hoSocket)   removeIfExists hoSocket   N.bind lsock (N.SockAddrUnix hoSocket)   setFileMode hoSocket 0o600-  N.listen lsock 128+  -- one holder takes every run of the user's: a long queue, so a burst of+  -- runs never finds it full (and takes it for gone)+  N.listen lsock (max 128 N.maxListenQueue)   ours <- fileID <$> getFileStatus hoSocket+  pid <- getProcessID   done <- newEmptyMVar-  let shutdown = do-        withLock (hoSocket <> ".lock") $ do-          N.close lsock `catch` \(_ :: SomeException) -> pure ()-          -- only our own socket: another holder may have replaced it-          st <- try (getFileStatus hoSocket)-          case st of-            Right s | fileID s == ours -> removeIfExists hoSocket-            Left (_ :: SomeException) -> pure ()-            _ -> pure ()-        -- what was taken before the close is finished first-        atomically $ readTVar clients >>= \n -> check (n == 0)+  let unlink = do+        N.close lsock `catch` \(_ :: SomeException) -> pure ()+        -- only our own socket: another holder may have replaced it+        st <- try (getFileStatus hoSocket)+        case st of+          Right s | fileID s == ours -> removeIfExists hoSocket+          Left (_ :: SomeException) -> pure ()+          _ -> pure ()+      close' = withLock (hoSocket <> ".lock") unlink+      -- the run asking holds the lock: let go of the socket under it+      drain = unlink >> writeIORef draining True+      touch = getMonotonicTime >>= writeIORef lastSeen+      -- what was taken before the close is finished first: however long+      -- that takes when idle, ten seconds when told to go+      shutdown grace = do+        close'+        let drained = atomically $ readTVar clients >>= \n -> check (n == 0)+        maybe drained (\secs -> void (timeout (round (secs * 1000000 :: Double)) drained)) grace         void (tryPutMVar done ())-  main' <- myThreadId-  _ <- installHandler sigTERM (Catch (void (forkIO shutdown))) Nothing-  _ <- installHandler sigHUP (Catch (void (forkIO shutdown))) Nothing+      status = do+        held <- muxHolding mux+        n <- readTVarIO clients+        (busy, _) <- muxActive mux+        pure $ object+          [ "t" .= ("status" :: Text), "protocol" .= holdProtocol, "version" .= hoVersion+          , "pid" .= (fromIntegral pid :: Int), "cap" .= hoCap, "hold_for" .= hoHoldFor+          , "clients" .= (n - 1 :: Int)   -- the one asking is not counted+          , "calls" .= busy+          , "held" .= [ object [ "host" .= substrateHost c, "port" .= substratePort c, "path" .= substratePath c+                               , "tls" .= isJust (substrateTls c), "agent" .= heldAgent h+                               , "calls" .= heldCalls h, "idle" .= (fromIntegral (round (heldIdle h * 10) :: Int) / 10 :: Double) ]+                      | h <- held, let c = heldConfig h ] ]+  _ <- installHandler sigTERM (Catch (void (forkIO (shutdown (Just 10))))) Nothing+  _ <- installHandler sigHUP (Catch (void (forkIO (shutdown (Just 10))))) Nothing+  -- a status asked, or a run only seeing that a holder answers, is no+  -- activity: only a call keeps a holder that holds nothing   _ <- forkIO $ forever $ do     r <- try (N.accept lsock)     case r of       Left (_ :: SomeException) -> threadDelay 100000       Right (s, _) -> do         atomically (modifyTVar' clients (+ 1))-        getMonotonicTime >>= writeIORef lastSeen         void $ forkIO $-          serveClient mux s `finally` do+          serveClient mux (Served status drain touch hoVersion) s `finally` do             N.close s `catch` \(_ :: SomeException) -> pure ()-            getMonotonicTime >>= writeIORef lastSeen             atomically (modifyTVar' clients (subtract 1))   _ <- forkIO $ forever $ do     threadDelay 1000000-    sweep mux hoHoldFor+    d <- readIORef draining+    sweep mux (if d then Just 0 else Nothing)     n <- readTVarIO clients     (busy, lastCall) <- muxActive mux+    held <- heldCount mux     seen <- readIORef lastSeen     now <- getMonotonicTime-    when (n == 0 && busy == 0 && now - max seen lastCall >= hoHoldFor) shutdown-  _ <- pure main'+    when (n == 0 && busy == 0 && held == 0 && (d || now - max seen lastCall >= hoHoldFor)) (shutdown Nothing)   takeMVar done   exitSuccess +-- | What a request may ask of the holder beside a call.+data Served = Served+  { svStatus  :: IO Value   -- ^ what it holds+  , svDrain   :: IO ()      -- ^ let go of the socket and wind down+  , svTouch   :: IO ()      -- ^ a call came or went+  , svVersion :: Text       -- ^ the synapse the holder is++  }+ -- | One run's request: read it, say it is taken, relay the call, say how--- it ended. The run closing its end cancels the call.-serveClient :: Mux -> N.Socket -> IO ()-serveClient mux s = do+-- it ended — or, asked for it, the status, or drain. The run closing its+-- end cancels the call.+serveClient :: Mux -> Served -> N.Socket -> IO ()+serveClient mux sv s = do   rd <- newLineReader s   line <- timeout 30000000 (readLine rd)   case line >>= id >>= decodeStrict of     Nothing -> pure ()+    Just v | isStatusRequest v -> svStatus sv >>= sendLine s+    Just v | isDrainRequest v -> do+      svDrain sv+      sendLine s (object ["t" .= ("draining" :: Text)])     Just v -> case decodeRequest v of       Nothing -> sendLine s (object ["t" .= ("error" :: Text), "why" .= ("synapse holder: a request it cannot read" :: Text)])-      Just (cfg, method, params) -> do-        sendLine s (object ["t" .= ("taken" :: Text)])-        let call = muxCall mux cfg+      Just (agent, holdFor, input, cfg, method, params) -> flip finally (svTouch sv) $ do+        svTouch sv+        sendLine s (object ["t" .= ("taken" :: Text), "v" .= svVersion sv])+        -- BIDIR-18: the call's input port, once the server has taken it;+        -- what the run sends for it goes out on the call's connection+        port <- newEmptyMVar+        failed <- newEmptyMVar+        let half | input = Just InputHalf+                     { ihPort = void . tryPutMVar port+                     , ihSignal = \g -> sendLine s (object ["t" .= ("sig" :: Text), "s" .= inputSignalValue g]) }+                 | otherwise = Nothing+            call = muxCallAs mux agent holdFor cfg half               (sendLine s (object ["t" .= ("sub" :: Text)]))               method params               (\i -> sendLine s (object ["t" .= ("item" :: Text), "i" .= i]))         withAsync call $ \a -> do-          -- the run going away (its end closed) ends the call+          -- the run going away (its end closed) ends the call; with an+          -- input half, the run's frames are relayed until then           watcher <- async $ do-            _ <- readLine rd+            r <- try (relayInput rd port)+            case r of+              Left (e :: SomeException)+                | Just (_ :: SomeAsyncException) <- fromException e -> throwIO e+                -- a frame the connection could not take: the call reports the loss+                | Just (_ :: LostConnection) <- fromException e -> void (readLine rd)+                -- anything else ends the call, said+                | otherwise -> void (tryPutMVar failed ("synapse holder: the input failed: " <> T.pack (show e)))+              Right Nothing -> pure ()+              Right (Just why) -> void (tryPutMVar failed why)             cancel a           r <- waitCatch a           cancel watcher+          bad <- tryTakeMVar failed           let reply = case r of+                _ | Just why <- bad -> object ["t" .= ("error" :: Text), "why" .= why]                 Right () -> object ["t" .= ("end" :: Text)]                 Left e | Just (LostConnection why sent) <- fromException e ->                   object ["t" .= ("lost" :: Text), "why" .= why, "sent" .= sent]                 Left e -> object ["t" .= ("error" :: Text), "why" .= errorText e]           sendLine s reply `catch` \(_ :: SomeException) -> pure () +-- | BIDIR-18: the run's input frames, each sent on the call's connection+-- through its port, until the run's end closes ('Nothing') or it sends+-- what the holder cannot read (why, and the call is ended).+relayInput :: LineReader -> MVar InputPort -> IO (Maybe Text)+relayInput rd port = loop+  where+    loop = do+      line <- readLine rd+      case line of+        Nothing -> pure Nothing+        Just l+          | Just rest <- BS8.stripPrefix "b " l+          , Just (sq, rest') <- BS8.readInt rest+          , Just (' ', lenText) <- BS8.uncons rest'+          , Just (len, "") <- BS8.readInt lenText+          , sq >= 0, len >= 0 -> if len > In.maxChunk+              then pure (Just ("synapse holder: a binary frame of " <> T.pack (show len)+                                <> " bytes is past the " <> T.pack (show In.maxChunk) <> " a frame may carry"))+              else do+              payload <- readExact rd len+              case payload of+                Nothing -> pure Nothing+                Just bytes -> do+                  p <- readMVar port+                  ipBinary p (fromIntegral sq) bytes >> loop+        Just l -> case decodeStrict l of+          Just (Object o)+            | Just (Object f) <- KM.lookup "in" o, Just sq <- seqOf f, Just (Array items) <- KM.lookup "items" f -> do+                p <- readMVar port+                ipItems p sq (foldr (:) [] items) >> loop+            | Just (Object f) <- KM.lookup "end" o, Just sq <- seqOf f -> do+                p <- readMVar port+                ipEnd p sq >> loop+          _ -> pure (Just "synapse holder: an input frame it cannot read")+    seqOf f = case KM.lookup "seq" f of+      Just (Number n) | n >= 0 -> Just (round n)+      _ -> Nothing+ -- | An exception as the run should rethrow it: an error call's message as -- it is ('connect' reports a failed dial as one), anything else shown. errorText :: SomeException -> Text@@ -483,18 +860,58 @@  instance Exception HolderGone --- | One call through the holder at the socket. Throws 'HolderGone' when--- the holder cannot be reached or closes before taking the request (the--- request did not go out); after that, what the holder reports.-holderCall :: FilePath -> SubstrateConfig -> ST.CallRoute-holderCall path cfg onSub method params onItem = do-  s <- N.socket N.AF_UNIX N.Stream N.defaultProtocol+-- | A connection to the holder's socket. A queue that is full answers+-- EAGAIN: that is a holder, busy, so it is tried again (for a second)+-- rather than taken for none.+openHolder :: FilePath -> IO N.Socket+openHolder path = go (50 :: Int)+  where+    go n = do+      s <- N.socket N.AF_UNIX N.Stream N.defaultProtocol+      r <- try (N.connect s (N.SockAddrUnix path))+      case r of+        Right () -> pure s+        Left (e :: SomeException) -> do+          N.close s `catch` \(_ :: SomeException) -> pure ()+          if busy e && n > 0 then threadDelay 20000 >> go (n - 1) else throwIO e++-- | EAGAIN: the listener's queue is full.+busy :: SomeException -> Bool+busy e = case fromException e of+  Just io -> fmap Errno (ioe_errno io) == Just eAGAIN+  Nothing -> False++-- | One call through the holder at the socket, as the agent. Throws+-- 'HolderGone' when the holder cannot be reached or closes before taking+-- the request (the request did not go out); after that, what the holder+-- reports.+holderCall :: FilePath -> Text -> SubstrateConfig -> ST.CallRoute+holderCall path agent cfg = holderCallDuplex path agent Nothing (const (pure ())) cfg Nothing++-- | 'holderCall', the connection kept for @holdFor@ seconds idle (else the+-- holder's own @--hold-for@), and @onTaken@ told the holder's synapse+-- version when it takes the request.+--+-- BIDIR-18: with an input half, the half's port writes the frames to the+-- holder (it sends them on the call's connection), and the holder's+-- relayed signals reach the half.+holderCallAs :: FilePath -> Text -> Maybe Double -> (Text -> IO ()) -> SubstrateConfig -> ST.CallRoute+holderCallAs path agent holdFor onTaken cfg = holderCallDuplex path agent holdFor onTaken cfg Nothing++-- | 'holderCallAs' for a call that may carry an input half.+holderCallDuplex :: FilePath -> Text -> Maybe Double -> (Text -> IO ()) -> SubstrateConfig -> ST.DuplexRoute+holderCallDuplex path agent holdFor onTaken cfg0 half onSub method params onItem = do+  -- a CA file named relative to this run's directory is named whole: the+  -- holder runs in / and serves every run+  cfg <- case substrateTls cfg0 of+    Just t -> (\fs -> cfg0 { substrateTls = Just t { tlsCaFiles = fs } }) <$> mapM makeAbsolute (tlsCaFiles t)+    Nothing -> pure cfg0+  c <- try (openHolder path)+  s <- case c of+    Left (e :: SomeException) -> throwIO (HolderGone (T.pack (show e)))+    Right s -> pure s   flip finally (N.close s `catch` \(_ :: SomeException) -> pure ()) $ do-    c <- try (N.connect s (N.SockAddrUnix path))-    case c of-      Left (e :: SomeException) -> throwIO (HolderGone (T.pack (show e)))-      Right () -> pure ()-    sent <- try (sendLine s (encodeRequest cfg method params))+    sent <- try (sendLine s (encodeRequest agent holdFor (isJust half) cfg method params))     case sent of       Left (e :: SomeException) -> throwIO (HolderGone (T.pack (show e)))       Right () -> pure ()@@ -506,8 +923,17 @@               | taken -> throwIO (LostConnection "the connection holder went away" True)               | otherwise -> throwIO (HolderGone "the holder closed before taking the request")             Just (Object o) -> case KM.lookup "t" o of-              Just (String "taken") -> loop True-              Just (String "sub") -> onSub >> loop True+              Just (String "taken") -> do+                mapM_ (\case { String v -> onTaken v; _ -> pure () }) (KM.lookup "v" o)+                loop True+              Just (String "sub") -> do+                mapM_ (\h -> ihPort h (holderPort s)) half+                onSub >> loop True+              Just (String "sig") -> do+                case KM.lookup "s" o >>= inputSignal of+                  Just g -> mapM_ (\h -> ihSignal h g) half+                  Nothing -> pure ()+                loop True               Just (String "item") -> do                 case fromJSON <$> KM.lookup "i" o of                   Just (Success i) -> onItem i@@ -524,22 +950,75 @@             Just _ -> loop taken     loop False --- | The holder's socket for an address and agent:--- @$XDG_RUNTIME_DIR/synapse/DIR\@AGENT-VERSION.sock@ (a temporary--- directory of the user's own without one). A name too long for a unix--- socket is shortened to a hash of itself.-socketPathFor :: Text -> FilePath -> Text -> IO FilePath-socketPathFor version addrDir agent = do-  base <- runtimeDir-  let name = addrDir <> "@" <> T.unpack (safe agent) <> "-" <> T.unpack version <> ".sock"-      full = base </> name-  pure $ if length full < 100 then full else base </> (show (fnv (T.pack name)) <> ".sock")+-- | BIDIR-18: a call's input port through the holder: each frame a line+-- to it (a binary one, its line and then its payload). A write the+-- holder's socket refuses is the call's connection lost — the frame may+-- not have gone, and nothing is sent again.+holderPort :: N.Socket -> InputPort+holderPort s = InputPort+  { ipItems = \sq items -> relay (sendLine s (object ["in" .= object ["seq" .= sq, "items" .= items]]))+  , ipBinary = \sq payload -> relay $ NB.sendMany s+      [ BS8.pack ("b " <> show sq <> " " <> show (BS.length payload) <> "\n"), payload ]+  , ipEnd = \sq -> relay (sendLine s (object ["end" .= object ["seq" .= sq]]))+  }   where-    safe = T.map (\ch -> if ch `elem` ("/\\\0" :: String) then '_' else ch)-    -- a stable hash of the name (polynomial, mod 2^61-1): only its length matters here-    fnv :: Text -> Integer-    fnv = T.foldl' (\h ch -> (h * 131 + fromIntegral (fromEnum ch)) `mod` 2305843009213693951) 7+    relay act = act `catch` \(e :: IOException) ->+      throwIO (LostConnection ("the connection holder went away (" <> T.pack (show e) <> ")") True) +-- | SYNAPSE-40: what the holder at the socket holds, as it says+-- (@{"t":"status", …}@), or Nothing when no holder answers there.+holderStatus :: FilePath -> IO (Maybe Value)+holderStatus path = do+  r <- try $ bracket (openHolder path) N.close $ \s -> do+    sendLine s (object ["status" .= True])+    rd <- newLineReader s+    timeout 5000000 (readLine rd)+  pure $ case r of+    Right (Just (Just line)) | Just v@(Object o) <- decodeStrict line+                             , KM.lookup "t" o == Just (String "status") -> Just v+    Left (_ :: SomeException) -> Nothing+    _ -> Nothing++-- | SYNAPSE-40: is synapse version @a@ older than @b@? (Unreadable: no.)+olderThan :: Text -> Text -> Bool+olderThan a b = case (parts a, parts b) of+  (Just x, Just y) -> x < y+  _ -> False+  where+    parts :: Text -> Maybe [Int]+    parts v = case T.splitOn "." (T.strip v) of+      [""] -> Nothing+      ps -> mapM (readMaybe . T.unpack) ps++-- | SYNAPSE-40: a run newer than the holder puts its own in its place.+-- Under the socket's lock, the holder is asked its version again; still+-- older, it is told to drain (it lets go of the socket at once, closes+-- what is idle, finishes what is open, and exits), and this run's binary+-- starts in its stead. A run of an older synapse never replaces a newer+-- holder, so the newest in use serves every run and two never take turns.+replaceHolder :: FilePath -> Double -> Text -> IO ()+replaceHolder path holdFor ours = void $ (try :: IO a -> IO (Either SomeException a)) $ do+  privateDir (takeDirectory path)+  withLock (path <> ".lock") $ do+    st <- holderStatus path+    case st of+      Just (Object o) | Just (String v) <- KM.lookup "version" o, olderThan v ours -> do+        drained <- try $ bracket (openHolder path) N.close $ \s -> do+          sendLine s (object ["drain" .= True])+          rd <- newLineReader s+          timeout 5000000 (readLine rd)+        case drained of+          Right (Just (Just _)) -> void (spawnHolder path holdFor)+          Left (_ :: SomeException) -> pure ()+          _ -> pure ()+      _ -> pure ()++-- | SYNAPSE-40: the one holder's socket:+-- @$XDG_RUNTIME_DIR/synapse/holder-4.sock@ (a temporary directory of the+-- user's own without one) — one per user and 'holdProtocol'.+holderSocketPath :: IO FilePath+holderSocketPath = (</> ("holder-" <> show holdProtocol <> ".sock")) <$> runtimeDir+ runtimeDir :: IO FilePath runtimeDir = do   x <- lookupEnv "XDG_RUNTIME_DIR"@@ -551,19 +1030,29 @@       pure (tmp </> ("synapse-" <> show uid))  -- | Start the holder for the socket unless one answers there, under the--- socket's lock (so parallel first runs start one), and wait for it.+-- socket's lock (so parallel first runs start one), and wait for it — up+-- to 3 s, or until it has exited without answering (a socket it could not+-- bind), so a run never waits on a holder that is not coming. ensureHolder :: FilePath -> Double -> IO Bool ensureHolder path holdFor = do   privateDir (takeDirectory path)   withLock (path <> ".lock") $ do     up <- answers path-    if up then pure True else do+    if up then pure True else spawnHolder path holdFor++-- | Start a holder at the socket and wait for it (the socket's lock held).+spawnHolder :: FilePath -> Double -> IO Bool+spawnHolder path holdFor = do       removeIfExists path       exe <- getExecutablePath       devnull <- openFile "/dev/null" ReadWriteMode       logH <- openFile (path <> ".log") WriteMode       setFileMode (path <> ".log") 0o600       procfs <- doesDirectoryExist "/proc/self/fd"+      -- SYNAPSE-40: the one holder outlives the run that starts it, so it+      -- takes nothing of that run's it does not need: not its directory (a+      -- worktree it would keep in use), nor a secret in its environment+      env <- filter ((`notElem` notForTheHolder) . fst) <$> getEnvironment       let args = ["_hold", "--socket", path, "--hold-for", show holdFor]           -- the holder keeps nothing of the run's but its own socket: every           -- descriptor past stderr is closed before it starts. close_fds@@ -572,17 +1061,28 @@           spawn             | procfs = (proc "/bin/sh" (["-c", closeOpenFds, "synapse-hold", exe] ++ args)) { close_fds = False }             | otherwise = (proc exe args) { close_fds = True }-      _ <- createProcess spawn+      (_, _, _, ph) <- createProcess spawn         { std_in = UseHandle devnull, std_out = UseHandle devnull, std_err = UseHandle logH-        , new_session = True }+        , new_session = True, cwd = Just "/", env = Just env }       hClose devnull       hClose logH       let wait (0 :: Int) = pure False           wait n = do             ok <- answers path-            if ok then pure True else threadDelay 5000 >> wait (n - 1)+            if ok then pure True else do+              ended <- getProcessExitCode ph+              case ended of+                Just _ -> pure False+                Nothing -> threadDelay 5000 >> wait (n - 1)       wait 600   -- 3 s +-- | What a run may carry in its environment that the holder must not take:+-- a key's seed, a session, a token (the holder signs nothing in; every+-- request brings its credential), and one run's keepalive (off, say), which+-- would be every connection's.+notForTheHolder :: [String]+notForTheHolder = ["PLEXUS_KEY", "PLEXUS_SESSION", "SYNAPSE_TOKEN", "PLEXUS_KEEPALIVE"]+ -- | A shell's start for the holder: close every descriptor past stderr -- that is open, then exec the holder (the arguments after @$0@). closeOpenFds :: String@@ -591,56 +1091,73 @@   , "case $n in 0|1|2|*[!0-9]*) ;; *) eval \"exec $n>&-\" 2>/dev/null ;; esac; done; "   , "exec \"$@\"" ] --- | Does something accept on the socket?+-- | Does a holder accept on the socket? One whose queue is full (EAGAIN+-- past 'openHolder''s second) is there, busy: never replaced for it. answers :: FilePath -> IO Bool answers path = do-  s <- N.socket N.AF_UNIX N.Stream N.defaultProtocol-  r <- try (N.connect s (N.SockAddrUnix path))-  N.close s `catch` \(_ :: SomeException) -> pure ()-  pure (either (\(_ :: SomeException) -> False) (const True) r)+  r <- try (openHolder path)+  case r of+    Right s -> True <$ (N.close s `catch` \(_ :: SomeException) -> pure ())+    Left e -> pure (busy e)  -- ============================================================================ -- The router -- ============================================================================  data RouterOpts = RouterOpts-  { roHold    :: Maybe (SubstrateConfig -> IO (Maybe FilePath))-    -- ^ the holder's socket for a call's address (and the run's agent), or-    -- Nothing for an address this run does not hold (a registry it only+  { roHold    :: Maybe (SubstrateConfig -> IO (Maybe Text))+    -- ^ the agent a call to this address is made as, through the holder,+    -- or Nothing for an address this run does not hold (a registry it only     -- glances at); Nothing at all: this process's multiplexer alone     -- (@--no-hold@)-  , roHoldFor :: Double+  , roSocket  :: FilePath   -- ^ the holder's socket ('holderSocketPath')+  , roHoldFor :: Double     -- ^ how long a connection is kept idle, sent with each call+  , roVersion :: Text       -- ^ this synapse's version: an older holder is replaced   } --- | Route every call of this process: through the holder for its address--- (started when there is none), else on this process's own shared--- connection.+-- | Route every call of this process: through the holder (started when+-- there is none), else on this process's own shared connection. installRouter :: RouterOpts -> IO Mux installRouter ro = do   mux <- newMux   broken <- newIORef False-  paths <- newIORef Map.empty-  ST.setCallRouter (\cfg -> Just (routedCall ro mux broken paths cfg))+  agents <- newIORef Map.empty+  replaced <- newIORef False+  ST.setDuplexRouter (\cfg -> Just (routedCall ro mux broken agents replaced cfg))   pure mux -routedCall :: RouterOpts -> Mux -> IORef Bool -> IORef (Map.Map ConnKey (Maybe FilePath)) -> SubstrateConfig -> ST.CallRoute-routedCall ro mux broken paths cfg onSub method params onItem = do+routedCall :: RouterOpts -> Mux -> IORef Bool -> IORef (Map.Map ConnKey (Maybe Text)) -> IORef Bool -> SubstrateConfig -> ST.DuplexRoute+routedCall ro mux broken agents replaced cfg half onSub method params onItem = do   b <- readIORef broken-  held <- case roHold ro of-    Just socketFor | not b -> do-      known <- Map.lookup (keyOf cfg) <$> readIORef paths-      p <- maybe (socketFor cfg) pure known-      modifyIORef' paths (Map.insert (keyOf cfg) p)-      pure p+  agent <- case roHold ro of+    Just agentFor | not b -> do+      let k = keyOf "" cfg+      known <- Map.lookup k <$> readIORef agents+      a <- maybe (agentFor cfg) pure known+      modifyIORef' agents (Map.insert k a)+      pure a     _ -> pure Nothing-  -- the socket's directory must be this user's alone (0700, owned by it)-  ours <- maybe (pure False) (dirIsOurs . takeDirectory) held-  case held of-    Just path | ours -> do-      let via = holderCall path cfg onSub method params onItem+  -- the socket's directory must be this user's alone (0700, owned by it),+  -- and the path short enough for a unix socket (107 bytes)+  let path = roSocket ro+      fits = BS.length (TE.encodeUtf8 (T.pack path)) < 108+  ours <- if fits then maybe (pure False) (const (dirIsOurs (takeDirectory path))) agent else pure False+  case agent of+    Just a | ours -> do+      seen <- newIORef Nothing+      let via = holderCallDuplex path a (Just (roHoldFor ro)) (writeIORef seen . Just) cfg half onSub method params onItem+          -- an older holder answered: once a run, this run's takes its place+          newer = do+            v <- readIORef seen+            done <- readIORef replaced+            case v of+              Just old | not done, olderThan old (roVersion ro) -> do+                writeIORef replaced True+                replaceHolder path (roHoldFor ro) (roVersion ro)+              _ -> pure ()       r <- try via       case r of-        Right () -> pure ()+        Right () -> newer         Left (HolderGone _) -> do           ok <- ensureHolder path (roHoldFor ro)           if not ok then local' else do@@ -648,12 +1165,12 @@             case r' of               Right () -> pure ()               Left (HolderGone _) -> local'-    _ -> muxCall mux cfg onSub method params onItem+    _ -> muxCallDuplex mux cfg half onSub method params onItem   where     -- no holder to be had: this process's own connection, for the rest of it     local' = do       writeIORef broken True-      muxCall mux cfg onSub method params onItem+      muxCallDuplex mux cfg half onSub method params onItem  -- ============================================================================ -- Files
src/Synapse/IR/Builder.hs view
@@ -3,20 +3,36 @@  -- | IR Builder - walks schema tree and constructs the IR ----- Uses the existing schema walker with a custom algebra that:--- 1. Reads types from $defs in methodParams, methodReturns and methodInput---    — whole, or refused by name (SYNAPSE-CC-8: "Synapse.IR.Read")--- 2. Deduplicates types by content hash, placing each shared type in the---    namespace that refers to it most (SYNAPSE-CC-8)--- 3. Infers streaming from return type structure--- 4. Builds method definitions with type references; server-injected params---    go to 'mdInjected', never 'mdParams'+-- IR 3.0 (plexus 0.7, RETURNS-13) over 2.1's reader (SYNAPSE-CC-8). The+-- schema walker runs a custom algebra that, for every method, reads from+-- the schema, never guessing:+--+-- 1. its item type (@returns@), error type (@error@) and progress type+--    (@progress@), each a 'TypeRef' — a 'RefNamed' with a 'TypeDef', or a+--    primitive / array / optional / JSON ref — and its params and @input@;+--    every type in them read whole, or refused by name ("Synapse.IR.Read");+-- 2. its cardinality (@cardinality@), as is ('mdCardinality');+-- 3. its params, server-injected ones going to 'mdInjected', never+--    'mdParams'.+--+-- One title in one namespace is one type: two methods whose schemas give a+-- name the same shape share one 'TypeDef'; two shapes under one name fail+-- the build ('TitleCollision'), naming both methods. Identical types in+-- different namespaces are then deduplicated, each placed in the namespace+-- that refers to it most (SYNAPSE-CC-8). module Synapse.IR.Builder   ( -- * Building IR     buildIR   , buildIRRaw+  , buildIRFromSchema+  , buildIRFromSchemas   , finalizeIR +    -- * Errors+  , IRBuildError(..)+  , TypeSource(..)+  , renderIRBuildError+     -- * Extraction (for testing)   , extractTypesFromSchema   , extractMethodDef@@ -24,6 +40,9 @@   , schemaToTypeRef   ) where +import Control.Applicative ((<|>))+import Control.Monad (foldM)+import Control.Monad.Except (throwError) import Control.Monad.IO.Class (liftIO) import Control.Monad.Reader (asks) import Data.Aeson (Value(..))@@ -43,6 +62,7 @@ import Synapse.Schema.Types import Synapse.Schema.Functor (SchemaF(..)) import Synapse.Algebra.Walk (walkSchemaPar)+import Synapse.Transport (fetchServiceIR, rootSchemaKnown) import Synapse.Monad import Synapse.IR.Types hiding (QualifiedName(..), qualifiedNameFull) import Synapse.IR.Types (QualifiedName(..), qualifiedNameFull, synapseVersion)@@ -50,6 +70,54 @@ import Plexus.Schema.Recursive (DeprecationInfo, ParamSchema(..), paramValues)  -- ============================================================================+-- Errors+-- ============================================================================++-- | Why no IR can be built from a schema.+data IRBuildError+  = TitleCollision+      { tcNamespace :: Text        -- ^ The namespace both types are in+      , tcTitle     :: Text        -- ^ The name they share+      , tcFirst     :: TypeSource  -- ^ Where the first shape was read+      , tcSecond    :: TypeSource  -- ^ Where the second, different, shape was read+      }+    -- ^ Two different shapes carry one name in one namespace (RETURNS-13).+    --   One name in one namespace is one Rust type, so this is a conflict+    --   the activation's author resolves; the IR never picks one.+  | MissingSchema+      { msPath :: Path             -- ^ A walk's root no schema was given for+      }+    -- ^ RETURNS-40: 'buildIRFromSchemas' was asked for a root it has no+    --   schema of (a walk from there would fail its first fetch).+  deriving stock (Show, Eq)++-- | Where a 'TypeDef' was read: a method, and which of its schemas.+data TypeSource = TypeSource+  { tsMethod :: Text  -- ^ The method's name+  , tsSchema :: Text  -- ^ @"params"@, @"returns"@, @"error"@, @"progress"@ or @"input"@+  }+  deriving stock (Show, Eq)++-- | One line, naming the title and both methods.+renderIRBuildError :: IRBuildError -> Text+renderIRBuildError TitleCollision{..} =+  "IR: two different types are titled \"" <> tcTitle <> "\" in namespace "+    <> renderNamespace tcNamespace <> ": " <> renderSource tcFirst+    <> " and " <> renderSource tcSecond+    <> ". One title in one namespace is one type; rename one of them."+  where+    renderNamespace ns+      | T.null ns = "(root)"+      | otherwise = "\"" <> ns <> "\""+    renderSource TypeSource{..} = "method " <> tsMethod <> " (" <> tsSchema <> ")"+renderIRBuildError MissingSchema{..} =+  "IR: no schema at " <> (if null msPath then "the root" else T.intercalate "." msPath)++-- | Raise an 'IRBuildError' in 'SynapseM'.+liftBuild :: Either IRBuildError a -> SynapseM a+liftBuild = either (throwError . ValidationError . renderIRBuildError) pure++-- ============================================================================ -- Building IR -- ============================================================================ @@ -75,10 +143,30 @@ -- After walking, deduplicate types that have identical structure -- Accepts generator info strings in "tool:version" format -- Uses parallel schema fetching for better performance+--+-- RETURNS-40: a service that writes its own IR (@<backend>.ir@, plexus-core+-- 0.8: its own schema lists @ir@, read from the run's memo or the schema+-- cache when they have it) is asked for it first, one call for the whole walk; its IR is the+-- one this walk would build from the same schemas. The walk is made only+-- for a service with no @ir@ (every 0.7.0 one), or from the schema cache+-- alone (SYNAPSE-28), which has no server to ask. buildIR :: [Text] -> Path -> SynapseM IR buildIR generatorInfoStrs path = do   backend <- asks seBackend-  raw <- walkSchemaPar irAlgebra path+  src <- asks seSchemaSource+  served <- case src of+    FromServer _ -> do+      -- review #5: asked only of a service whose own schema lists `ir`+      -- (every 0.7.0 one lists none: no call is spent on it)+      root <- rootSchemaKnown+      if maybe False (any ((== "ir") . methodName) . psMethods) root+        then fetchServiceIR path+        else pure Nothing+    FromCache _ -> pure Nothing+  built <- case served of+    Just (Right ir) -> pure ir+    Just (Left why) -> throwError (ValidationError why)+    Nothing -> finalizeIR <$> walkSchemaPar irAlgebra path    -- Parse generator info and add synapse itself   let parsedGens = mapMaybe parseGeneratorInfo generatorInfoStrs@@ -95,7 +183,7 @@         , gmIrVersion = irVersion emptyIR         } -  pure $ finalizeIR raw+  pure built     { irBackend = backend     , irMetadata = Just metadata     }@@ -104,6 +192,57 @@ buildIRRaw :: Path -> SynapseM IR buildIRRaw = walkSchemaPar irAlgebra +-- | Build IR from one plugin schema, with no backend: what 'buildIR' builds+--   for a walk that starts at @path@ and finds this schema there, children+--   aside (they are summaries; nothing fetches them). 'irMetadata' is+--   'Nothing' — the toolchain and the clock are 'buildIR''s to record — so+--   the result is a pure function of its arguments (RETURNS-13: the+--   conformance fixture's golden @ir.json@ is built with it, and RETURNS-40's+--   server-side IR is held equal to it).+buildIRFromSchema+  :: Text          -- ^ Backend name ('irBackend')+  -> Path          -- ^ The path the schema sits at; its namespace is the path joined with "."+  -> PluginSchema+  -> Either IRBuildError IR+buildIRFromSchema backend path schema = do+  raw <- pluginIR path schema []+  pure $ (finalizeIR raw)+    { irBackend = backend+    , irMetadata = Nothing+    }++-- | RETURNS-40: what 'buildIR' builds for a walk from each of @roots@,+--   with every schema it would fetch given (by path, the root at @[]@),+--   less 'irMetadata': the walk's own fold — a node's IR from its schema and+--   its children's, in its @children@ order, a child no schema is given for+--   skipped as a walk skips one whose fetch failed — each root's IR merged+--   left to right, then 'finalizeIR'. A node whose schemas put two shapes+--   under one title is refused as the walk refuses it ('pluginIRRefusing'),+--   so only a root with no schema fails. One root is exactly what+--   @synapse -i <backend> <root>@ prints. The IR a service writes itself+--   (plexus-core's @<service>.ir@) is held equal to it.+buildIRFromSchemas+  :: Text                      -- ^ Backend name ('irBackend')+  -> [Path]                    -- ^ The walks' roots+  -> Map Path PluginSchema     -- ^ Every schema the walks read, by path+  -> Either IRBuildError IR+buildIRFromSchemas backend roots schemas = do+  irs <- traverse walk roots+  let raw = foldr mergeIR emptyIR irs+  pure $ (finalizeIR raw)+    { irBackend = backend+    , irMetadata = Nothing+    }+  where+    walk path = case Map.lookup path schemas of+      Nothing -> Left (MissingSchema path)+      Just schema -> do+        let kids = [ path ++ [csNamespace c] | c <- fromMaybe [] (psChildren schema), Map.member (path ++ [csNamespace c]) schemas ]+        childIRs <- traverse walk kids+        -- the walk's own node: a title collision refuses the activation,+        -- not the walk (RETURNS-36 review #9)+        pure (pluginIRRefusing path schema childIRs)+ -- | SYNAPSE-CC-8: the walk's IR made whole — shared types deduplicated and --   placed where they are referred to most, every ref that names no type --   turned to 'RefUnknown' (and refused, unless the type it names was), the@@ -122,14 +261,50 @@ -- - Build method definitions -- - Merge with child results irAlgebra :: SchemaF IR -> SynapseM IR-irAlgebra (PluginF schema path childIRs) = do+irAlgebra (PluginF schema path childIRs) = pure (pluginIRRefusing path schema childIRs)++irAlgebra (MethodF method namespace path) = liftBuild $ do+  -- Single method node (shouldn't happen in normal walk, but handle it)+  let fullPath = T.intercalate "." path+  ((types, mdef), refusals) <- extractMethodDefR namespace fullPath method+  pure $ IR+    { irVersion = irVersion emptyIR  -- Use version from emptyIR+    , irBackend = irBackend emptyIR  -- Will be set by buildIR+    , irHash = Nothing  -- Methods don't carry hash+    , irMetadata = Nothing  -- Will be set by buildIR+    , irTypes = types+    , irMethods = Map.singleton fullPath mdef+    , irPlugins = Map.singleton namespace [methodName method]+    , irPluginHashes = Nothing  -- Methods don't have plugin-level hashes+    , irPluginRequests = Nothing  -- Methods don't carry request schemas+    , irPluginRoles = Nothing  -- Methods don't carry roles+    , irRefusals = refusals+    }++-- | RETURNS-36 review (#9): one plugin node as the walk builds it. A plugin+--   whose schemas put two shapes under one title ('TitleCollision') is+--   refused, not the walk: its methods and types are left out, a refusal at+--   its namespace names both sites, and its children are built as ever. A+--   refusal makes @-i@ exit 65, as any does.+pluginIRRefusing :: Path -> PluginSchema -> [IR] -> IR+pluginIRRefusing path schema childIRs = case pluginIR path schema childIRs of+  Right ir -> ir+  Left err ->+    let bare = either (const emptyIR) id (pluginIR path schema { psMethods = [] } childIRs)+        site = T.intercalate "." path+        why = maybe (renderIRBuildError err) id (T.stripPrefix "IR: " (renderIRBuildError err))+    in bare { irRefusals = Refusal site "title" Nothing (why <> " The activation is left out.") : irRefusals bare }++-- | One plugin node: its own types and methods, merged with its children's.+pluginIR :: Path -> PluginSchema -> [IR] -> Either IRBuildError IR+pluginIR path schema childIRs = do   -- Use full path as namespace to avoid collisions   -- e.g., "hyperforge.workspace.repos" instead of just "repos"   let namespace = T.intercalate "." path       pathPrefix = namespace  -- Same as namespace    -- Extract types and methods from this plugin-  let (localTypes, localMethods, localRefusals) = extractFromPlugin namespace pathPrefix schema+  (localTypes, localMethods, localRefusals) <- extractFromPlugin namespace pathPrefix schema    -- Merge with children   let childIR = foldr mergeIR emptyIR childIRs@@ -145,14 +320,12 @@       childHashes = fromMaybe Map.empty (irPluginHashes childIR)       allHashes = Map.insert namespace hashInfo childHashes -  -- REQ-5: capture per-plugin PlexusRequest schema (psRequest from wire schema).-  -- HEADERS-1 stub: plexus-protocol's PluginSchema does not yet expose-  -- psRequest (cross-repo patch pending). Treat as always-Nothing so the-  -- synapse binary builds; REQ-5 rendering becomes a no-op until the field-  -- lands in plexus-protocol.-  let _ = schema  -- suppress unused-binding warning when REQ-5 is stubbed-      childRequests = fromMaybe Map.empty (irPluginRequests childIR)-      allRequests = childRequests+  -- REQ-5: capture per-plugin PlexusRequest schema (psRequest from the wire+  -- schema, CONTEXT-5 — HEADERS-1's stub is gone now plexus-protocol reads it).+  let childRequests = fromMaybe Map.empty (irPluginRequests childIR)+      allRequests = case psRequest schema of+        Just reqV -> Map.insert namespace reqV childRequests+        Nothing   -> childRequests    pure $ IR     { irVersion = irVersion emptyIR  -- Use version from emptyIR@@ -171,24 +344,6 @@     , irRefusals = localRefusals <> irRefusals childIR     } -irAlgebra (MethodF method namespace path) = do-  -- Single method node (shouldn't happen in normal walk, but handle it)-  let fullPath = T.intercalate "." path-  let ((types, mdef), refusals) = extractMethodDefR namespace fullPath method-  pure $ IR-    { irVersion = irVersion emptyIR  -- Use version from emptyIR-    , irBackend = irBackend emptyIR  -- Will be set by buildIR-    , irHash = Nothing  -- Methods don't carry hash-    , irMetadata = Nothing  -- Will be set by buildIR-    , irTypes = types-    , irMethods = Map.singleton fullPath mdef-    , irPlugins = Map.singleton namespace [methodName method]-    , irPluginHashes = Nothing  -- Methods don't have plugin-level hashes-    , irPluginRequests = Nothing  -- Methods don't carry request schemas-    , irPluginRoles = Nothing  -- Methods don't carry roles-    , irRefusals = refusals-    }- -- ============================================================================ -- Type Deduplication and Placement -- ============================================================================@@ -263,7 +418,8 @@     names = concatMap methodNames (Map.elems (irMethods ir))          ++ concatMap (kindNames . tdKind) (Map.elems (irTypes ir))     methodNames md = concatMap refKeys-      (map pdType (mdParams md) ++ [mdReturns md] ++ maybe [] pure (mdInput md) ++ maybe [] pure (mdBidirType md))+      (map pdType (mdParams md) ++ [mdReturns md] ++ maybe [] pure (mdError md) ++ maybe [] pure (mdProgress md)+        ++ maybe [] pure (mdInput md) ++ maybe [] pure (mdBidirType md))     kindNames = \case       KindStruct fs -> concatMap (refKeys . fdType) fs       KindEnum _ vs _ -> concatMap (\v -> concatMap (refKeys . fdType) (vdFields v) ++ maybe [] refKeys (vdRef v)) vs@@ -316,6 +472,8 @@ updateMethodRefs redirects md = md   { mdParams = map (updateParamRefs redirects) (mdParams md)   , mdReturns = updateTypeRef redirects (mdReturns md)+  , mdError = fmap (updateTypeRef redirects) (mdError md)+  , mdProgress = fmap (updateTypeRef redirects) (mdProgress md)   , mdBidirType = fmap (updateTypeRef redirects) (mdBidirType md)   , mdInput = fmap (updateTypeRef redirects) (mdInput md)   -- mdRole, mdBidirResponse*: left unchanged via record-update syntax@@ -409,10 +567,14 @@         let path = mdFullPath md             ps = [ let (t, rs) = fix (siteParam path (pdName p)) (pdType p) in (p { pdType = t }, rs) | p <- mdParams md ]             (ret, rr) = fix (siteReturns path) (mdReturns md)-            (inp, ri) = case mdInput md of+            optional site = \case               Nothing -> (Nothing, [])-              Just x -> let (x', rs) = fix (siteInput path) x in (Just x', rs)-        in (md { mdParams = map fst ps, mdReturns = ret, mdInput = inp }, concatMap snd ps ++ rr ++ ri)+              Just x -> let (x', rs) = fix site x in (Just x', rs)+            (err, re) = optional (siteError path) (mdError md)+            (prog, rp) = optional (siteProgress path) (mdProgress md)+            (inp, ri) = optional (siteInput path) (mdInput md)+        in ( md { mdParams = map fst ps, mdReturns = ret, mdError = err, mdProgress = prog, mdInput = inp }+           , concatMap snd ps ++ rr ++ re ++ rp ++ ri )       types' = Map.mapWithKey fixType (irTypes ir)       methods' = Map.map fixMethod (irMethods ir)   in ( ir { irTypes = Map.map fst types', irMethods = Map.map fst methods' }@@ -424,10 +586,13 @@  -- | Extract all types and methods from a plugin schema -- Types are namespace-qualified to avoid collisions (e.g., "cone.ListResult")-extractFromPlugin :: Text -> Text -> PluginSchema -> (Map Text TypeDef, Map Text MethodDef, [Refusal])-extractFromPlugin namespace pathPrefix schema =+--+-- Fails when two of its schemas give one name two shapes ('TitleCollision').+extractFromPlugin :: Text -> Text -> PluginSchema -> Either IRBuildError (Map Text TypeDef, Map Text MethodDef, [Refusal])+extractFromPlugin namespace pathPrefix schema = do+  checkTitles namespace (psMethods schema)   let -- every method's documents share one namespace's $defs: read once-      docs = [ o | m <- psMethods schema, Just (Object o) <- [methodParams m, methodReturns m, methodInput m] ]+      docs = [ o | m <- psMethods schema, (_, Object o) <- methodDocs m ]       shared = sharedDoc namespace docs       ((defs, results), refusals) = runRead $ do         ds <- readDefs shared@@ -435,23 +600,108 @@         pure (ds, rs)       allTypes = Map.unions (defs : map fst results)       allMethods = Map.fromList [ (mdFullPath m, m) | (_, m) <- results ]-  in (allTypes, allMethods, refusals)+  pure (allTypes, allMethods, refusals)  -- | Extract types and method def from a single method (its refusals dropped: --   'extractMethodDefR' keeps them).-extractMethodDef :: Text -> Text -> MethodSchema -> (Map Text TypeDef, MethodDef)-extractMethodDef namespace pathPrefix = fst . extractMethodDefR namespace pathPrefix+--+-- Fails when two of the method's own schemas give one name two shapes.+extractMethodDef :: Text -> Text -> MethodSchema -> Either IRBuildError (Map Text TypeDef, MethodDef)+extractMethodDef namespace pathPrefix = fmap fst . extractMethodDefR namespace pathPrefix  -- | Extract types and method def from a single method, and every site the --   reader refused on the way.-extractMethodDefR :: Text -> Text -> MethodSchema -> ((Map Text TypeDef, MethodDef), [Refusal])-extractMethodDefR = extractMethodDefIn Nothing+extractMethodDefR :: Text -> Text -> MethodSchema -> Either IRBuildError ((Map Text TypeDef, MethodDef), [Refusal])+extractMethodDefR namespace pathPrefix method = do+  checkTitles namespace [method]+  pure (extractMethodDefIn Nothing namespace pathPrefix method)  -- | 'extractMethodDefR', its $defs read from a namespace's shared document --   when one is given (and so not read here), else from its own documents. extractMethodDefIn :: Maybe Doc -> Text -> Text -> MethodSchema -> ((Map Text TypeDef, MethodDef), [Refusal]) extractMethodDefIn shared namespace pathPrefix = runRead . methodDefRead shared namespace pathPrefix +-- | A method's schema documents, each by the name 'TypeSource' gives it.+methodDocs :: MethodSchema -> [(Text, Value)]+methodDocs m =+  [ (k, v)+  | (k, Just v) <-+      [ ("params", methodParams m)+      , ("returns", Just (methodReturns m))+      , ("error", methodError m)+      , ("progress", methodProgress m)+      , ("input", methodInput m)+      ]+  ]++-- | The fallback name of a method's untitled named root, by schema.+rootFallback :: MethodSchema -> Text -> Text+rootFallback m = \case+  "returns" -> methodName m <> "Result"+  "error" -> methodName m <> "Error"+  "progress" -> methodName m <> "Progress"+  "input" -> methodName m <> "Input"+  other -> methodName m <> other++-- ============================================================================+-- One name, one type (RETURNS-13)+-- ============================================================================++-- | RETURNS-13: one name in one namespace is one type. Every name the+--   methods' schemas define — each @$defs@ entry, and each named root+--   ('rootNamed') — in the methods' order, then params, returns, error,+--   progress, input; where two give a name different bodies, both are read+--   (each in its own document) and their kinds compared, descriptions aside.+--   Two shapes are a 'TitleCollision' naming the first and the second; the+--   same shape is one type (the reader keeps the first).+--+--   The bodies are compared as JSON first, so a namespace whose methods+--   carry the same @$defs@ again and again (as schemars writes them) costs+--   one equality test a copy, not a read.+checkTitles :: Text -> [MethodSchema] -> Either IRBuildError ()+checkTitles namespace methods = mapM_ checkName (Map.toList byName)+  where+    byName :: Map Text [(Value, KM.KeyMap Value, TypeSource)]+    byName = Map.fromListWith (flip (++))+      [ (name, [(body, docObj, TypeSource (methodName m) which)])+      | m <- methods+      , (which, doc@(Object docObj)) <- methodDocs m+      , (name, body) <- defsOf docObj ++ rootOf m which doc+      ]+    defsOf docObj = case KM.lookup "$defs" docObj <|> KM.lookup "definitions" docObj of+      Just (Object d) -> [ (K.toText k, v) | (k, v) <- KM.toList d ]+      _ -> []+    rootOf m which doc+      | which == "params" = []+      | otherwise = maybe [] pure (rootNamed (rootFallback m which) doc)++    checkName (name, occurrences) = case occurrences of+      [] -> Right ()+      (first : rest) -> () <$ foldM (compareWith name) (first, Nothing) rest++    -- the first occurrence, and its kind once read+    compareWith name (first@(b0, d0, s0), k0) (b, d, s)+      | b == b0 = Right (first, k0)+      | otherwise =+          let kind0 = maybe (readKindIn namespace d0 name b0) id k0+              kind = readKindIn namespace d name b+          in if sameShape kind0 kind+               then Right (first, Just kind0)+               else Left TitleCollision+                 { tcNamespace = namespace, tcTitle = name, tcFirst = s0, tcSecond = s }++-- | Two readings of a name are one type when they agree on everything but+--   their documentation.+sameShape :: Maybe TypeKind -> Maybe TypeKind -> Bool+sameShape a b = fmap undocumented a == fmap undocumented b+  where+    undocumented = \case+      KindStruct fields -> KindStruct (map field fields)+      KindEnum disc variants repr -> KindEnum disc (map variant variants) repr+      other -> other+    field fd = fd { fdDescription = Nothing }+    variant vd = vd { vdDescription = Nothing, vdFields = map field (vdFields vd) }+ methodDefRead :: Maybe Doc -> Text -> Text -> MethodSchema -> SchemaRead (Map Text TypeDef, MethodDef) methodDefRead shared namespace pathPrefix method = do   let name = methodName method@@ -465,23 +715,28 @@     readParams shared namespace fullPath (methodParams method) (methodParamSchemas method)    -- Returns: a child's is its client (SYNAPSE-CC-8's RefChild); an rpc-  -- method's is read whole, and its absence is refused.-  RootRead returnTypes returnRef streaming <- case methodRole method of-    MethodRoleRpc -> case methodReturns method of-      Just v -> readRoot shared namespace (siteReturns fullPath) (name <> "Result") v-      Nothing -> do-        refuse (siteReturns fullPath) "returns" Nothing "an rpc method with no returns schema"-        pure (RootRead Map.empty RefUnknown False)+  -- method's is read whole (plexus 0.7: every method states it).+  RootRead returnTypes returnRef <- case methodRole method of+    MethodRoleRpc ->+      readRoot shared namespace (siteReturns fullPath) (rootFallback method "returns") (methodReturns method)     _child -> do       defs <- case (shared, methodReturns method) of-        (Nothing, Just (Object o)) -> readDefs (docOf namespace o)+        (Nothing, Object o) -> readDefs (docOf namespace o)         _ -> pure Map.empty-      pure (RootRead defs (RefChild fullPath) False)+      pure (RootRead defs (RefChild fullPath)) -  -- Input: reserved for BIDIR-6; read when a server says it.-  inputRead <- traverse (readRoot shared namespace (siteInput fullPath) (name <> "Input")) (methodInput method)+  -- RETURNS-13: the rest of the contract — the error type of a+  -- Result<T, E> item and the progress type P, each its own document.+  errorRead <- traverse (readRoot shared namespace (siteError fullPath) (rootFallback method "error")) (methodError method)+  progressRead <- traverse (readRoot shared namespace (siteProgress fullPath) (rootFallback method "progress")) (methodProgress method) -  let allTypes = Map.unions [paramTypes, returnTypes, maybe Map.empty rrTypes inputRead]+  -- Input (BIDIR-6): read when a server says it. BIDIR-18 / BIDIR-19: an+  -- Input<Bytes> item (`x-plexus-binary`) is a string of format "binary"+  -- (`RefPrimitive "string" (Just "binary")`, as service-codegen reads it).+  inputRead <- traverse (readRoot shared namespace (siteInput fullPath) (rootFallback method "input") . binaryInput) (methodInput method)++  let allTypes = Map.unions+        [paramTypes, returnTypes, maybe Map.empty rrTypes errorRead, maybe Map.empty rrTypes progressRead, maybe Map.empty rrTypes inputRead]   pure     ( allTypes     , MethodDef@@ -489,9 +744,12 @@         , mdFullPath = fullPath         , mdNamespace = namespace         , mdDescription = Just (methodDescription method)-        , mdStreaming = streaming         , mdParams = params         , mdReturns = returnRef+        , mdError = rrRef <$> errorRead+        , mdProgress = rrRef <$> progressRead+        , mdCardinality = methodCardinality method+          -- RETURNS-13: the schema's cardinality, never inferred         , mdBidirType = inferBidirType method         , mdBidirResponseType = Nothing  -- TODO: could extract from response_type title         , mdBidirResponseSchema = methodResponseType method@@ -609,3 +867,14 @@ extractTypesFromSchema :: Text -> Value -> Map Text TypeDef extractTypesFromSchema namespace (Object o) = fst (runRead (readDefs (docOf namespace o))) extractTypesFromSchema _ _ = Map.empty++-- | BIDIR-18: an @Input<Bytes>@ item schema (@"x-plexus-binary": true@,+-- with its @contentEncoding@ and @contentMediaType@) as the IR reads it:+-- @{"type":"string","format":"binary"}@, its description kept. Anything+-- else is itself.+binaryInput :: Value -> Value+binaryInput = \case+  Object o | KM.lookup "x-plexus-binary" o == Just (Bool True) -> Object $ KM.fromList $+    [("type", String "string"), ("format", String "binary")]+      ++ [ ("description", d) | Just d <- [KM.lookup "description" o] ]+  v -> v
src/Synapse/IR/Read.hs view
@@ -31,11 +31,15 @@   , readDefs   , readRoot   , RootRead(..)+  , rootNamed+  , readKindIn     -- * Type positions   , readType     -- * Sites   , siteParam   , siteReturns+  , siteError+  , siteProgress   , siteInput     -- * The vocabulary   , knownKeywords@@ -96,9 +100,10 @@   (# (), s { rsRefusals = Refusal site kw variant detail : rsRefusals s } #)  -- | A named root read before. One name in one namespace is one type: the---   first schema read under it is the type, as the first of a name in---   @$defs@ is (and as IR 2.0 kept it), so a later root of the same name is---   not read again (comparing the schemas cost more than the read).+--   builder refuses two shapes under one name before reading+--   (RETURNS-13's 'Synapse.IR.Builder.TitleCollision'), so the first schema+--   read under a name stands for every later root of it, which is not read+--   again. rootSeen :: Text -> Value -> SchemaRead (Maybe (Maybe TypeKind)) rootSeen site _ = SchemaRead $ \s -> case Map.lookup site (rsRoots s) of   Just (_, k) -> (# Just k, s #)@@ -120,23 +125,27 @@ data Doc = Doc   { docNamespace :: Text   , docDefs      :: KM.KeyMap Value+  , docRoot      :: Maybe Text  -- ^ The root's name: what @"$ref": "#"@ names (RETURNS-13)   }  docOf :: Text -> KM.KeyMap Value -> Doc-docOf ns o = Doc ns $ case KM.lookup "$defs" o of-  Just (Object d) -> d-  _ -> case KM.lookup "definitions" o of-    Just (Object d) -> d-    _ -> KM.empty+docOf ns o = Doc ns defs Nothing+  where+    defs = case KM.lookup "$defs" o of+      Just (Object d) -> d+      _ -> case KM.lookup "definitions" o of+        Just (Object d) -> d+        _ -> KM.empty  -- | One namespace's documents as one: every @$defs@ entry of them, the --   first of each name winning (as the types the builder kept always did). --   Read once, a namespace's definitions are read once, not once a method. sharedDoc :: Text -> [KM.KeyMap Value] -> Doc-sharedDoc ns docs = Doc ns (foldl (\acc o -> KM.union acc (docDefs (docOf ns o))) KM.empty docs)+sharedDoc ns docs = Doc ns (foldl (\acc o -> KM.union acc (docDefs (docOf ns o))) KM.empty docs) Nothing  -- | The definition a @$ref@ names, in this document. lookupDef :: Doc -> Text -> Maybe Value+lookupDef _ "#" = Nothing lookupDef doc ref = KM.lookup (K.fromText (refName ref)) (docDefs doc)  refName :: Text -> Text@@ -145,6 +154,10 @@   [] -> ref  qualified :: Doc -> Text -> TypeRef+qualified doc "#" = case docRoot doc of+  -- RETURNS-13: @"#"@ is the document's root, by its name+  Just root -> RefNamed QualifiedName { qnNamespace = docNamespace doc, qnLocalName = root }+  Nothing -> RefNamed QualifiedName { qnNamespace = docNamespace doc, qnLocalName = "#" } qualified doc ref = RefNamed QualifiedName { qnNamespace = docNamespace doc, qnLocalName = refName ref }  typeSite :: Doc -> Text -> Text@@ -156,6 +169,12 @@ siteReturns :: Text -> Text siteReturns path = path <> " returns" +siteError :: Text -> Text+siteError path = path <> " error"++siteProgress :: Text -> Text+siteProgress path = path <> " progress"+ siteInput :: Text -> Text siteInput path = path <> " input" @@ -168,7 +187,7 @@ knownKeywords :: Set.Set Text knownKeywords = Set.fromList   [ "type", "$ref", "properties", "required", "additionalProperties", "items", "prefixItems"-  , "oneOf", "anyOf", "enum", "const", "format"+  , "oneOf", "anyOf", "allOf", "enum", "const", "format"   , "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum"   , "minLength", "maxLength", "pattern", "minItems", "maxItems"   , "default", "description", "title", "examples", "deprecated"@@ -193,7 +212,7 @@ isJsonSchema :: KM.KeyMap Value -> Bool isJsonSchema o = not (any (`KM.member` o) structural) || allSix   where-    structural = ["type", "$ref", "properties", "additionalProperties", "items", "prefixItems", "oneOf", "anyOf", "enum", "const"]+    structural = ["type", "$ref", "properties", "additionalProperties", "items", "prefixItems", "oneOf", "anyOf", "allOf", "enum", "const"]     allSix = case KM.lookup "type" o of       Just (Array ts) ->         Set.fromList [ t | String t <- V.toList ts ] == jsonTypes@@ -232,6 +251,10 @@ readTypeObject :: Doc -> Text -> KM.KeyMap Value -> SchemaRead TypeRef readTypeObject doc site o   | Just (String ref) <- KM.lookup "$ref" o = pure (if isJsonRef ref then RefJson else qualified doc ref)+  | Just members <- allOfOf o = case members of+      -- RETURNS-13: @allOf [X]@ is X (draft-07 tooling wraps a ref so)+      [x] | not (any (`KM.member` o) ["properties", "type", "oneOf", "anyOf"]) -> readType doc site x+      _ -> unknownAt site "allOf" Nothing "allOf is read only as one schema, or beside a tag as a newtype variant's struct"   | Just (kw, members) <- unionOf o = readInlineUnion doc site kw members   | Just (Array ts) <- KM.lookup "type" o =       let types = [ t | String t <- V.toList ts ]@@ -357,6 +380,21 @@ -- Unions -- ============================================================================ +-- | A schema's @allOf@ members.+allOfOf :: KM.KeyMap Value -> Maybe [Value]+allOfOf o = case KM.lookup "allOf" o of+  Just (Array ms) -> Just (V.toList ms)+  _ -> Nothing++-- | What a newtype variant (or a struct) wraps: a @$ref@ beside its+--   properties (2020-12), or @allOf [{"$ref": …}]@ (draft-07) — RETURNS-13.+wrappedRef :: KM.KeyMap Value -> Maybe Value+wrappedRef m = case KM.lookup "$ref" m of+  Just r -> Just r+  Nothing -> case allOfOf m of+    Just [Object x] | Just r@(String _) <- KM.lookup "$ref" x -> Just r+    _ -> Nothing+ -- | A schema's union: its keyword and members. unionOf :: KM.KeyMap Value -> Maybe (Text, [Value]) unionOf o = case (KM.lookup "oneOf" o, KM.lookup "anyOf" o) of@@ -475,7 +513,7 @@   | Just (Object props) <- KM.lookup "properties" m = Just VariantShape       { vsProps    = [ (K.toText k, v) | (k, v) <- KM.toList props ]       , vsRequired = requiredOf m-      , vsWrapped  = KM.lookup "$ref" m+      , vsWrapped  = wrappedRef m       , vsDesc     = descriptionOf member       } variantShape _ _ _ = Nothing@@ -654,7 +692,7 @@       when (maybe False (\ap -> ap /= Bool False) (KM.lookup "additionalProperties" o)) $         refuse site "additionalProperties" Nothing "a map beside properties (a flattened map) has no IR form"       fields <- fieldsOf doc site (requiredOf o) (propsOf o)-      wrapped <- case KM.lookup "$ref" o of+      wrapped <- case wrappedRef o of         Nothing -> pure []         Just w -> wrappedFields doc site w       pure (Just (KindStruct (nubBy (\a b -> fdName a == fdName b) (fields ++ wrapped))))@@ -679,29 +717,56 @@ -- A document's root -- ============================================================================ --- | What a document's root (a method's @returns@ or @input@) reads as.+-- | What a document's root (a method's @returns@, @error@, @progress@ or+--   @input@) reads as. data RootRead = RootRead   { rrTypes     :: Map Text TypeDef   -- ^ its @$defs@, and the root's own type when it is named   , rrRef       :: TypeRef            -- ^ the root as a ref-  , rrStreaming :: Bool               -- ^ a union of more than one non-error variant   } +-- | A document's root as a type of its own, when it is one: its name (its+--   title, else @fallback@) and its schema less its @$defs@. A struct, an+--   enum and a union (not a nullable) are named; so is a @$ref@ under a+--   title of its own, as an alias (RETURNS-13). Anything else — a+--   primitive, an array, a nullable, @{}@ — is a plain ref, whatever+--   schemars titles it (@String@, @Array_of_T@).+rootNamed :: Text -> Value -> Maybe (Text, Value)+rootNamed fallback = \case+  Object o+    | named -> Just (name, body)+    | Just (String ref) <- KM.lookup "$ref" o, not (KM.member "properties" o)+    , Just t <- title, t /= refName ref, ref /= "#" -> Just (t, body)+    where+      title = case KM.lookup "title" o of+        Just (String t) | not (T.null t) -> Just t+        _ -> Nothing+      name = fromMaybe fallback title+      named = isJust (unionOf o) && isNothing (nullableMember (maybe [] snd (unionOf o)))+           || KM.member "properties" o+           || KM.member "enum" o+      body = Object (KM.delete "$defs" (KM.delete "definitions" o))+  _ -> Nothing++-- | A named type's kind read alone, in its own document (a method's schema+--   whose @$defs@ its refs resolve against), its refusals dropped: what the+--   builder compares when two schemas give one name two different bodies.+readKindIn :: Text -> KM.KeyMap Value -> Text -> Value -> Maybe TypeKind+readKindIn namespace docObj name v =+  let doc = (docOf namespace docObj) { docRoot = Just name }+  in fst (runRead (readKind doc (typeSite doc name) v))+ -- | A document's root: a titled (or else @fallback@-named) struct, enum or---   union is a type of its own; anything else is the plain ref.+--   union, or a titled @$ref@, is a type of its own ('rootNamed'); anything+--   else is the plain ref. Nothing is inferred from it: a method's+--   cardinality is its schema's (RETURNS-13). readRoot :: Maybe Doc -> Text -> Text -> Text -> Value -> SchemaRead RootRead readRoot shared namespace site fallback = \case-  Object o -> do-    let doc = maybe (docOf namespace o) id shared-        name = case KM.lookup "title" o of-          Just (String t) | not (T.null t) -> t-          _ -> fallback-        named = isJust (unionOf o) && isNothing (nullableMember (maybe [] snd (unionOf o)))-             || KM.member "properties" o-             || KM.member "enum" o-    defs <- maybe (readDefs doc) (const (pure Map.empty)) shared-    if named-      then do-        let body = Object (KM.delete "$defs" (KM.delete "definitions" o))+  v@(Object o) -> do+    let doc0 = maybe (docOf namespace o) id shared+    defs <- maybe (readDefs doc0) (const (pure Map.empty)) shared+    case rootNamed fallback v of+      Just (name, body) -> do+        let doc = doc0 { docRoot = Just name }             tsite = typeSite doc name         seen <- rootSeen tsite body         kind <- case seen of@@ -717,16 +782,11 @@             pure RootRead               { rrTypes = Map.insert (tdFullName td) td defs               , rrRef = ref-              , rrStreaming = streams k               }-          Nothing -> pure RootRead { rrTypes = defs, rrRef = RefUnknown, rrStreaming = False }-      else do-        ref <- readType doc site (Object (KM.delete "$defs" (KM.delete "definitions" o)))-        pure RootRead { rrTypes = defs, rrRef = ref, rrStreaming = False }+          Nothing -> pure RootRead { rrTypes = defs, rrRef = RefUnknown }+      Nothing -> do+        ref <- readType doc0 site (Object (KM.delete "$defs" (KM.delete "definitions" o)))+        pure RootRead { rrTypes = defs, rrRef = ref }   other -> do-    ref <- readType (Doc namespace KM.empty) site other-    pure RootRead { rrTypes = Map.empty, rrRef = ref, rrStreaming = False }-  where-    streams = \case-      KindEnum _ vs Internal -> length (filter ((/= "error") . vdName) vs) > 1-      _ -> False+    ref <- readType (Doc namespace KM.empty Nothing) site other+    pure RootRead { rrTypes = Map.empty, rrRef = ref }
src/Synapse/IR/Types.hs view
@@ -19,7 +19,9 @@ -- -- - Types are hoisted to top level and deduplicated by name -- - Methods reference types by name, not inline definitions--- - Streaming is inferred from return type structure+-- - A method's contract is read from its schema, never inferred: its item+--   type ('mdReturns'), error type ('mdError'), progress type+--   ('mdProgress') and cardinality ('mdCardinality') — IR 3.0, plexus 0.7 -- - Discriminated unions are first-class module Synapse.IR.Types   ( -- * Top-level IR@@ -47,6 +49,7 @@     -- * Method Definitions   , MethodDef(..)   , MethodRole(..)+  , Cardinality(..)   , ParamDef(..)   , InjectedParam(..) @@ -64,10 +67,6 @@   , TypeRef(..)   , typeRefName   , typeRefChildren--    -- * Utilities-  , isStreaming-  , inferStreaming   ) where  import Control.Applicative ((<|>))@@ -81,17 +80,20 @@ import Data.Maybe (catMaybes) import Data.Text (Text) import qualified Data.Text as T+import Data.Version (showVersion) import GHC.Generics (Generic) -import Plexus.Schema.Recursive (DeprecationInfo(..), MethodRole(..))+import Paths_plexus_synapse (version)+import Plexus.Schema.Recursive (Cardinality(..), DeprecationInfo(..), MethodRole(..))  -- ============================================================================ -- Version Information -- ============================================================================ --- | Synapse version (from cabal file: plexus-synapse.cabal)+-- | Synapse's own package version (@plexus-synapse.cabal@'s @version@), as+--   recorded in 'GenerationMetadata'. synapseVersion :: Text-synapseVersion = "0.2.0.0"+synapseVersion = T.pack (showVersion version)  -- ============================================================================ -- Generation Metadata@@ -185,10 +187,12 @@     <*> o .:? "irPluginRoles"     <*> o .:? "irRefusals" .!= [] --- | The IR format this synapse writes (SYNAPSE-CC-8). A cache holding any---   other is discarded and built again.+-- | The IR format this synapse writes: 3.0 (RETURNS-13: a method's returns,+--   error, progress and cardinality, read from the plexus 0.7 schema) over+--   2.1's forms (SYNAPSE-CC-8). A cache holding any other is discarded and+--   built again. currentIrVersion :: Text-currentIrVersion = "2.1"+currentIrVersion = "3.0"  -- ============================================================================ -- IR 2.1: refusals, injected params, refinements@@ -284,7 +288,7 @@ -- | Empty IR for folding emptyIR :: IR emptyIR = IR-  { irVersion = currentIrVersion  -- 2.1: SYNAPSE-CC-8 (2.0: TypeRef uses structured QualifiedName)+  { irVersion = currentIrVersion  -- 3.0: MethodDef carries returns · error · progress · cardinality (plexus 0.7); 2.1's SYNAPSE-CC-8 forms kept   , irBackend = ""  -- Will be set by buildIR   , irHash = Nothing   , irMetadata = Nothing@@ -349,7 +353,7 @@       { ksFields :: [FieldDef]       -- ^ Struct fields       }   | KindEnum-      { keDiscriminator :: Text      -- ^ Field that discriminates (e.g., "type"); @""@ when not 'Internal'+      { keDiscriminator :: Text      -- ^ The tag: the property whose @const@ every variant carries (e.g., "type", "kind"); @""@ when not 'Internal'       , keVariants :: [VariantDef]   -- ^ Possible variants       , keRepr :: EnumRepr           -- ^ SYNAPSE-CC-8: internal (omitted), external or untagged       }@@ -388,7 +392,7 @@ data VariantDef = VariantDef   { vdName        :: Text           -- ^ Variant name (the discriminator value)   , vdDescription :: Maybe Text     -- ^ Documentation-  , vdFields      :: [FieldDef]     -- ^ Fields specific to this variant+  , vdFields      :: [FieldDef]     -- ^ The variant's fields, the tag aside; a newtype variant over a struct carries the struct's fields   , vdRef         :: Maybe TypeRef  -- ^ SYNAPSE-CC-8: an untagged variant's type (omitted when absent)   }   deriving stock (Show, Eq, Generic)@@ -404,14 +408,32 @@ -- ============================================================================  -- | A method definition+--+--   IR 3.0: a method's contract is four facts read from its 0.7+--   'Plexus.Schema.Recursive.MethodSchema', never inferred — its item type+--   ('mdReturns'), its error type when it can say no ('mdError'), its+--   progress type when it reports progress ('mdProgress'), and whether it+--   yields one item or many ('mdCardinality'). Every 'RefNamed' among them+--   has a 'TypeDef' in 'irTypes'. data MethodDef = MethodDef   { mdName        :: Text           -- ^ Method name (e.g., "chat")   , mdFullPath    :: Text           -- ^ Full path (e.g., "cone.chat")   , mdNamespace   :: Text           -- ^ Parent namespace (e.g., "cone")   , mdDescription :: Maybe Text     -- ^ Documentation-  , mdStreaming   :: Bool           -- ^ Does this method stream multiple events?   , mdParams      :: [ParamDef]     -- ^ Input parameters-  , mdReturns     :: TypeRef        -- ^ Return type reference+  , mdReturns     :: TypeRef+    -- ^ The item type @T@: what one success carries (for @Result<T, E>@ or+    --   @Progress<T, P>@, @T@ alone). From @MethodSchema.returns@.+  , mdError       :: Maybe TypeRef+    -- ^ The error type @E@ of a @Result<T, E>@ item — what the activation+    --   says when it says no. 'Nothing' when the method is infallible.+    --   From @MethodSchema.error@.+  , mdProgress    :: Maybe TypeRef+    -- ^ The progress type @P@ its progress frames carry. 'Nothing' when the+    --   method reports no progress. From @MethodSchema.progress@.+  , mdCardinality :: Cardinality+    -- ^ One item or many. From @MethodSchema.cardinality@ — never inferred+    --   from the shape of 'mdReturns'.   , mdBidirType   :: Maybe TypeRef     -- ^ Bidirectional channel type parameter T, when the method uses     -- BidirChannel<StandardRequest<T>, StandardResponse<T>>.@@ -466,21 +488,24 @@   deriving stock (Show, Eq, Generic)  -- | Manual JSON instances so @mdRole@ defaults to 'MethodRoleRpc' when---   deserializing pre-IR-12 IR JSON (same back-compat posture as the---   Rust @#[serde(default)]@ on @MethodDef.md_role@ in service-codegen).+--   absent (same posture as the Rust @#[serde(default)]@ on+--   @MethodDef.md_role@ in service-codegen). -----   All other fields preserve the generic derivation defaults (field---   names used verbatim as JSON keys; @Maybe@ fields omitted when---   @Nothing@ to match aeson's legacy behaviour).+--   Field names are the JSON keys; a 'Nothing' is written as @null@, as+--   the generic derivation always has. @mdCardinality@ is @"one"@ /+--   @"many"@ (the schema's own spelling) and is required: IR 3.0 does not+--   read 2.0's @mdStreaming@ (RETURNS-1 decision 12, no back-compat). instance ToJSON MethodDef where   toJSON MethodDef{..} = object $     [ "mdName"                .= mdName     , "mdFullPath"            .= mdFullPath     , "mdNamespace"           .= mdNamespace     , "mdDescription"         .= mdDescription-    , "mdStreaming"           .= mdStreaming     , "mdParams"              .= mdParams     , "mdReturns"             .= mdReturns+    , "mdError"               .= mdError+    , "mdProgress"            .= mdProgress+    , "mdCardinality"         .= mdCardinality     , "mdBidirType"           .= mdBidirType     , "mdBidirResponseType"   .= mdBidirResponseType     , "mdBidirResponseSchema" .= mdBidirResponseSchema@@ -502,9 +527,11 @@     <*> o .:  "mdFullPath"     <*> o .:  "mdNamespace"     <*> o .:? "mdDescription"-    <*> o .:  "mdStreaming"     <*> o .:  "mdParams"     <*> o .:  "mdReturns"+    <*> o .:? "mdError"+    <*> o .:? "mdProgress"+    <*> o .:  "mdCardinality"     <*> o .:? "mdBidirType"     <*> o .:? "mdBidirResponseType"     <*> o .:? "mdBidirResponseSchema"@@ -627,23 +654,3 @@ typeRefName :: TypeRef -> Maybe Text typeRefName (RefNamed qn) = Just (qualifiedNameFull qn) typeRefName _ = Nothing---- ============================================================================--- Utilities--- ============================================================================---- | Check if a method definition is streaming-isStreaming :: MethodDef -> Bool-isStreaming = mdStreaming---- | Infer streaming from a return type's structure--- A method is streaming if its return type is an enum with more than 2 variants--- (more than 1 success variant + error variant)-inferStreaming :: TypeDef -> Bool-inferStreaming td = case tdKind td of-  KindEnum _ variants _ ->-    let nonErrorVariants = filter (not . isErrorVariant) variants-    in length nonErrorVariants > 1-  _ -> False-  where-    isErrorVariant v = vdName v == "error"
src/Synapse/Identity/Session.hs view
@@ -48,6 +48,8 @@   , printedAny   , noteRefused   , wasRefused+  , noteFailed+  , hasFailed   , currentCeiling   ) where @@ -458,11 +460,13 @@  -- | The in-band error a service answers a call whose session has ended with. isSessionEndedItem :: ServiceStreamItem -> Bool-isSessionEndedItem = \case+isSessionEndedItem item = case item of   ServiceData _ _ _ (Object o)     | KM.lookup "type" o == Just (String "error")     , Just (String m) <- KM.lookup "message" o -> isSessionEndedText m-  ServiceError _ _ e _ -> isSessionEndedText e+  -- RETURNS-36 re-review #6: main's text on any error item — the server's+  -- message, and so the typed unauthenticated's reason where there is one+  ServiceError _ _ m _ -> isSessionEndedText m   _ -> False  isSessionEndedText :: Text -> Bool@@ -509,6 +513,18 @@  wasRefused :: IO Bool wasRefused = readIORef refusedRef++-- | A stream said its call failed: an error item, or a data item whose+-- content is an event of @type: error@. The call exits non-zero.+{-# NOINLINE failedRef #-}+failedRef :: IORef Bool+failedRef = unsafePerformIO (newIORef False)++noteFailed :: IO ()+noteFailed = writeIORef failedRef True++hasFailed :: IO Bool+hasFailed = readIORef failedRef  -- | AUTH-8: the ceiling of the session this process carries, as asked at -- login (@editor\@MENGER-12@ each) — what a refusal that names none is
+ src/Synapse/Input.hs view
@@ -0,0 +1,589 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}++-- | BIDIR-18: synapse calls a method with an input half (@Input<T>@,+-- plexus-wire spec/10).+--+-- stdin (@--input -@) or a file (@--input \@path@) is the stream in:+--+-- * a string item: one line, one item;+-- * a bytes item (@"x-plexus-binary": true@, @Input<Bytes>@): the bytes cut+--   into @--chunk N@ (4096 unless said), each one WebSocket binary frame;+-- * anything else: NDJSON, one JSON value a line — a line that does not+--   decode is an error naming its line number, and no frame carries it.+--+-- Items are read only while the callee's credit allows: with no credit,+-- nothing more is read, so the pipe in front of synapse is the buffer and+-- synapse's memory stays flat however far the writer runs ahead. What is+-- ready goes out together, up to 256 items (or about 1 MiB) a frame. At+-- the end of the input, @<ns>.end@; the output prints until its terminal+-- item, as any call's.+--+-- Nothing is sent twice (spec/10: no resume; the owner, 2026-11-01:+-- buffer locally, delete only as a last resort, always warn on a+-- disconnect). A connection lost during the call is said on stderr every+-- time, with what was sent and what the callee acknowledged, and the run+-- exits 69. A typed close exits by its kind: @past_credit@ 70 (synapse's+-- own bug), @invalid_input@, @out_of_sequence@, @too_much_input@ 65, a+-- revoked or ended sign-in 77.+module Synapse.Input+  ( -- * The flags+    InputNames(..)+  , FlagArity(..)+  , inputNames+  , InputFlags(..)+  , takeInputFlags+  , defaultChunk+  , maxChunk++    -- * The source+  , InputSource(..)+  , sourceOf+  , ItemKind(..)+  , itemKind++    -- * Feeding a call+  , Tally(..)+  , Fed(..)+  , withInputFeed+  , renderTally+  , closeExit+  , inputRefusal++    -- * A dry run+  , dryRunFrame+  ) where++import Control.Concurrent (forkIO, threadDelay)+import Control.Concurrent.Async (async, cancel, waitCatch, waitEitherCatch, withAsync)+import Control.Concurrent.STM+import Control.Exception (Exception, SomeException, IOException, catch, fromException, throwIO, try)+import Control.Monad (forM_, unless, void, when)+import Data.Aeson (Value(..), eitherDecodeStrict', encode, object, (.=))+import qualified Data.Aeson.KeyMap as KM+import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as BS8+import qualified Data.ByteString.Lazy.Char8 as LBS8+import Data.Char (isSpace)+import Data.IORef+import qualified Data.Map.Strict as Map+import Data.Maybe (fromMaybe, isJust)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import qualified Data.Text.IO as TIO+import Data.Word (Word64)+import Numeric (showHex)+import System.FilePath ((</>), isRelative)+import System.IO.Error (isEOFError)+import System.IO (Handle, IOMode(..), hClose, hFlush, hReady, hSetBinaryMode, openBinaryFile, stderr, stdin)++import Plexus.Client (LostConnection(..))+import Synapse.CLI.Parse (FlagArity(..), takeFlag)+import Plexus.Input+import Plexus.Types (PlexusStreamItem(..))+import qualified Plexus.RemoteError as RE++-- ---------------------------------------------------------------------------+-- The flags++-- | What the input's flags are called for a method: @--input@, @--chunk@,+-- @--progress@ — or, when a parameter of the method already has one of+-- those names, @--input-stream@, @--input-chunk@, @--input-progress@ (the+-- parameter keeps its flag).+data InputNames = InputNames+  { inStream   :: Text+  , inChunk    :: Text+  , inProgress :: Text+  } deriving (Eq, Show)++-- | The names, given the method's parameter names.+inputNames :: [Text] -> InputNames+inputNames params = InputNames+  { inStream = pick "input" "input-stream"+  , inChunk = pick "chunk" "input-chunk"+  , inProgress = pick "progress" "input-progress"+  }+  where+    pick plain other = if plain `elem` params then other else plain++-- | What the input's flags said.+data InputFlags = InputFlags+  { ifSource   :: Maybe Text   -- ^ @-@ or @\@path@ (Nothing: not given)+  , ifChunk    :: Maybe Int+  , ifProgress :: Bool+  } deriving (Eq, Show)++-- | The most bytes one binary frame carries (and the holder relays): 16 MiB.+maxChunk :: Int+maxChunk = 16 * 1024 * 1024++-- | 4096 bytes a binary frame unless @--chunk@ says.+defaultChunk :: Int+defaultChunk = 4096++-- | Take the input's flags out of a method's flag arguments (as+-- 'Synapse.CLI.Parse.parsePathAndParams' gave them): what they said, and+-- the arguments left for the method's parameters. @--input@ and @--chunk@+-- take the next argument whatever it begins with (@-@ is stdin), or+-- @=value@; @--progress@ is a switch (@=true@ / @=false@ too). Any other+-- flag keeps the value its parameter takes (@arity@, by its normalized+-- key), so a value that reads @--input@ is that parameter's, never the+-- stream's flag.+takeInputFlags :: InputNames -> (Text -> FlagArity) -> [Text] -> Either Text (InputFlags, [Text])+takeInputFlags names arity = go (InputFlags Nothing Nothing False) []+  where+    go acc rest [] = Right (acc, reverse rest)+    go acc rest (x : xs) = case T.stripPrefix "--" x of+      Just flag+        | (key, eq) <- T.breakOn "=" flag, norm key == norm (inStream names) ->+            withValue key eq xs $ \v xs' -> go acc { ifSource = Just v } rest xs'+        | (key, eq) <- T.breakOn "=" flag, norm key == norm (inChunk names) ->+            withValue key eq xs $ \v xs' -> case reads (T.unpack v) of+              [(n, "")] | n > 0 && n <= maxChunk -> go acc { ifChunk = Just n } rest xs'+              _ -> Left ("--" <> key <> " takes a number of bytes (1 to " <> T.pack (show maxChunk) <> "), not '" <> v <> "'")+        | (key, eq) <- T.breakOn "=" flag, norm key == norm (inProgress names) ->+            case T.drop 1 eq of+              _ | T.null eq -> go acc { ifProgress = True } rest xs+              v | v `elem` ["true", "1"] -> go acc { ifProgress = True } rest xs+                | v `elem` ["false", "0"] -> go acc { ifProgress = False } rest xs+                | otherwise -> Left ("--" <> key <> " is a switch (true or false), not '" <> v <> "'")+        | otherwise ->+            let (_, _, n) = takeFlag arity flag xs+                (taken, xs') = splitAt n xs+            in go acc (reverse taken ++ x : rest) xs'+      _ -> go acc (x : rest) xs+    withValue key eq xs k+      | not (T.null eq) = k (T.drop 1 eq) xs+      | (v : xs') <- xs = k v xs'+      | otherwise = Left ("--" <> key <> " takes a value: - (stdin) or @FILE")+    norm = T.replace "_" "-"++-- ---------------------------------------------------------------------------+-- The source++-- | Where the items come from.+data InputSource = FromStdin | FromFile FilePath+  deriving (Eq, Show)++-- | @-@ is stdin, @\@path@ a file (@~/@ and a relative path resolved as+-- SYNAPSE-10's @\@file@ are); anything else is refused, naming the flag.+sourceOf :: Text -> FilePath -> Maybe FilePath -> Text -> Either Text InputSource+sourceOf flag cwd home v+  | v == "-" = Right FromStdin+  | Just p <- T.stripPrefix "@" v, not (T.null p), not ("@" `T.isPrefixOf` p) = Right (FromFile (resolve (T.unpack p)))+  | otherwise = Left ("--" <> flag <> " takes - (stdin) or @FILE, not '" <> v <> "'")+  where+    resolve ('~' : '/' : rest) | Just h <- home = h </> rest+    resolve p | isRelative p = cwd </> p+              | otherwise = p++-- | Items read from a handle.+data Reader = Reader+  { rdNext  :: IO (Maybe Item)  -- ^ the next item, waiting for it; Nothing at the end+  , rdTry   :: IO Next          -- ^ the next item if it is whole now, never waiting+  , rdAtEnd :: IO Bool          -- ^ the source is known to be over, no item read for it+  }++data Item = JsonItem Value Int | BytesItem BS.ByteString++-- | What 'rdTry' found.+data Next = Got Item | Ended | NotYet++-- | A line that is not an item: its number and why.+data BadLine = BadLine Int Text+  deriving Show++instance Exception BadLine++-- | The source as items. Only the end of the source ends it: any other+-- failure to read is raised (the call is ended, nothing more sent). Lines+-- are cut from what the source has given so far, so a whole line is sent+-- while the next is still arriving; bytes are what the source has, up to+-- the chunk, never waiting to fill one (a live source's 20 ms go as they+-- come).+newReader :: ItemKind -> Int -> Handle -> IO Reader+newReader kind chunk h = do+  hSetBinaryMode h True+  lineNo <- newIORef (0 :: Int)+  buf <- newIORef BS.empty+  eof <- newIORef False+  let -- more of the source, waiting for it; at the end, eof+      fill = do+        b <- BS.hGetSome h 8192   -- what the handle holds: no more read ahead of credit+        if BS.null b then writeIORef eof True else modifyIORef' buf (<> b)+      -- the source has bytes (or its end) for a read that will not wait;+      -- a failure here is said by that read+      readable = hReady h `catch` \(_ :: IOException) -> pure True+      cut = do+        b <- readIORef buf+        case BS8.elemIndex '\n' b of+          Just i -> do+            writeIORef buf (BS.drop (i + 1) b)+            Just <$> numbered (BS.take i b)+          Nothing -> pure Nothing+      lastLine = do+        b <- readIORef buf+        writeIORef buf BS.empty+        if BS.null b then pure Nothing else Just <$> numbered b+      numbered l = do+        n <- atomicModifyIORef' lineNo (\k -> (k + 1, k + 1))+        pure (n, stripCr l)+      lineWaiting = cut >>= \case+        Just l -> pure (Just l)+        Nothing -> readIORef eof >>= \case+          True -> lastLine+          False -> fill >> lineWaiting+      lineNow = cut >>= \case+        Just l -> pure (Got' l)+        Nothing -> readIORef eof >>= \case+          True -> maybe Ended' Got' <$> lastLine+          False -> readable >>= \case+            True -> fill >> lineNow+            False -> pure NotYet'+      item (n, l) = case kind of+        ItemString -> case TE.decodeUtf8' l of+          Right t -> pure (Just (JsonItem (String t) (BS.length l)))+          Left _ -> throwIO (BadLine n "not UTF-8 text")+        _ | BS8.all isSpace l -> pure Nothing   -- a blank line between values is none+          | otherwise -> case eitherDecodeStrict' l of+              Right v -> pure (Just (JsonItem v (BS.length l)))+              Left e -> throwIO (BadLine n (T.pack e))+      nextLine = lineWaiting >>= \case+        Nothing -> pure Nothing+        Just l -> item l >>= maybe nextLine (pure . Just)+      tryLine = lineNow >>= \case+        Got' l -> item l >>= maybe tryLine (pure . Got)+        Ended' -> pure Ended+        NotYet' -> pure NotYet+      nextBytes = readIORef eof >>= \case+        True -> pure Nothing+        False -> do+          b <- BS.hGetSome h chunk+          if BS.null b then writeIORef eof True >> pure Nothing else pure (Just (BytesItem b))+      atEnd = do+        b <- readIORef buf+        e <- readIORef eof+        if not (BS.null b) then pure False+          else if e then pure True+          -- hReady says the end without taking an item: it raises it+          else (False <$ hReady h) `catch` \(err :: IOException) -> pure (isEOFError err)+  pure Reader+    { rdNext = case kind of+        ItemBytes -> nextBytes+        _ -> nextLine+    , rdTry = case kind of+        ItemBytes -> maybe Ended Got <$> nextBytes   -- never asked: a bytes item is a frame+        _ -> tryLine+    , rdAtEnd = atEnd }+  where+    stripCr l | not (BS.null l), BS8.last l == '\r' = BS.init l+              | otherwise = l++-- | A line as 'newReader' finds it without waiting.+data Line = Got' (Int, BS.ByteString) | Ended' | NotYet'++openSource :: InputSource -> IO Handle+openSource = \case+  FromStdin -> pure stdin+  FromFile p -> openBinaryFile p ReadMode++-- ---------------------------------------------------------------------------+-- Feeding a call++-- | What the input half did: what went out, and what the callee said.+data Tally = Tally+  { tItems    :: !Integer        -- ^ items sent+  , tFrames   :: !Integer        -- ^ frames sent (JSON and binary)+  , tAcked    :: !(Maybe Integer)  -- ^ the last frame the callee consumed+  , tUnacked  :: !Integer        -- ^ items sent and not yet acknowledged+  , tCredit   :: !Integer        -- ^ credit left+  , tEnded    :: !Bool           -- ^ @end@ went out+  , tClosed   :: !(Maybe (Text, Maybe Integer))  -- ^ @input_closed {reason, ack}@+  } deriving (Eq, Show)++-- | How the input half ended, beside the call's own ending.+data Fed+  = FedAll                 -- ^ the input ended and @end@ went out, or the callee closed it+  | FedBadLine Int Text    -- ^ a line that is not an item: nothing carried it; the call was ended+  | FedFailed Text         -- ^ the source could not be read+  deriving (Eq, Show)++data FeedState = FeedState+  { fsPort    :: TMVar InputPort+  , fsCredit  :: TVar Integer+  , fsClosed  :: TVar (Maybe (Text, Maybe Integer))+  , fsAcked   :: TVar (Maybe Integer)+  , fsItems   :: TVar Integer+  , fsFrames  :: TVar Integer+  , fsThrough :: TVar (Map.Map Integer Integer)  -- ^ frame seq → items sent through it, for frames not yet acked+  , fsAckedItems :: TVar Integer                -- ^ items in the frames acked+  , fsEnded   :: TVar Bool+  , fsDone    :: TVar Bool                      -- ^ the call is over: stop+  , fsGranted :: TVar Bool                      -- ^ a credit came: the source may have been read+  }++-- | The most items, and about the most bytes, one @<ns>.input@ carries.+-- | Whose turn it is in the feeding.+data Turn = Go | Stop | Over | Poll++frameItems, frameBytes :: Int+frameItems = 256+frameBytes = 1024 * 1024++-- | Run a call with its input fed from the source. @call@ makes the call+-- with the half it is given (through a route that hands the half its port+-- once the server has taken the call). The source is opened first (a file+-- that cannot be read is refused before anything is sent). Returns how the+-- call ended (its own exception kept), how the feeding ended, and the+-- tally. A bad line cancels the call. @call@ is also given whether the+-- source is untouched yet (no credit has come, so nothing was read): a+-- call refused then may be made again without losing an item.+withInputFeed+  :: ItemKind -> Int -> Bool -> InputSource+  -> ((InputHalf, IO Bool) -> IO a)+  -> IO (Either SomeException a, Fed, Tally)+withInputFeed kind chunk progress src call = do+  h <- openSource src+  rd <- newReader kind chunk h+  st <- FeedState <$> newEmptyTMVarIO <*> newTVarIO 0 <*> newTVarIO Nothing <*> newTVarIO Nothing+                  <*> newTVarIO 0 <*> newTVarIO 0 <*> newTVarIO Map.empty <*> newTVarIO 0 <*> newTVarIO False <*> newTVarIO False <*> newTVarIO False+  let half = InputHalf+        { ihPort = atomically . void . tryPutTMVar (fsPort st)+        , ihSignal = signal st }+  feeder <- async (feed kind rd st)+  ticker <- if progress then Just <$> async (tick st) else pure Nothing+  callA <- async (call (half, not <$> readTVarIO (fsGranted st)))+  first <- waitEitherCatch feeder callA+  (result, fed) <- case first of+    -- the call ended first: the feeder may be blocked reading the source,+    -- which no exception interrupts — it is let go, not waited for+    Right r -> do+      atomically (writeTVar (fsDone st) True)+      void (forkIO (cancel feeder))+      pure (r, FedAll)+    Left (Right ()) -> do+      r <- waitCatch callA+      pure (r, FedAll)+    Left (Left e)+      | Just (BadLine n why) <- fromException e -> do+          -- nothing carried the line: the call ends here, unsubscribed+          cancel callA+          r <- waitCatch callA+          pure (r, FedBadLine n why)+      | Just (_ :: LostConnection) <- fromException e -> do+          -- a frame the connection would not take: the call reports why+          r <- waitCatch callA+          pure (r, FedAll)+      | otherwise -> do+          cancel callA+          r <- waitCatch callA+          pure (r, FedFailed (T.pack (show e)))+  atomically (writeTVar (fsDone st) True)+  mapM_ cancel ticker+  case src of+    FromFile _ -> hClose h `catch` \(_ :: SomeException) -> pure ()+    FromStdin -> pure ()+  t <- tallyOf st+  pure (result, fed, t)++-- | What the callee said, kept for the feeder.+signal :: FeedState -> InputSignal -> IO ()+signal st = \case+  InputCredit n -> atomically $ do+    writeTVar (fsGranted st) True+    modifyTVar' (fsCredit st) (+ max 0 n)+  InputAck sq -> atomically (acked sq)+  InputClosed reason ack -> atomically $ do+    writeTVar (fsClosed st) (Just (reason, ack))+    forM_ ack acked+  where+    -- every frame through sq consumed: its items counted, its entry let go+    acked sq = do+      writeTVar (fsAcked st) (Just sq)+      through <- readTVar (fsThrough st)+      forM_ (Map.lookupLE sq through) $ \(_, n) -> modifyTVar' (fsAckedItems st) (max n)+      writeTVar (fsThrough st) (snd (Map.split sq through))++-- | The feeding: wait for the port and for credit; read what credit+-- allows (the first item waits for the source, the rest only what is+-- ready); send it as one frame (a bytes item as a binary frame of its+-- own); at the end of the source, @end@. The callee closing the half stops+-- it, reading no more.+feed :: ItemKind -> Reader -> FeedState -> IO ()+feed _kind rd st = do+  port <- atomically (readTMVar (fsPort st))+  let loop sq = do+        turn <- waitTurn+        case turn of+          Stop -> pure ()+          -- the source is over with no credit left: end carries no item+          Over -> end port sq+          Go -> do+            first <- rdNext rd+            case first of+              Nothing -> end port sq+              Just (BytesItem b) -> do+                stillOpen <- open'+                when stillOpen $ do+                  sent sq 1+                  ipBinary port sq b+                  loop (sq + 1)+              Just (JsonItem v n) -> gather port sq [v] 1 n+      -- credit, or the call over, or (looked at every 50 ms while there is+      -- no credit) the source over+      waitTurn = do+        timer <- registerDelay 50000+        t <- atomically $+          (do done <- readTVar (fsDone st)+              closed <- readTVar (fsClosed st)+              check (done || isJust closed)+              pure Stop)+          `orElse` (readTVar (fsCredit st) >>= check . (> 0) >> pure Go)+          `orElse` (readTVar timer >>= check >> pure Poll)+        case t of+          Poll -> rdAtEnd rd >>= \over -> if over then pure Over else waitTurn+          other -> pure other+      -- more of what is whole now, within the credit and the frame's size;+      -- what is gathered goes out as soon as nothing more is+      gather port sq acc count size = do+        c <- readTVarIO (fsCredit st)+        if not (c > fromIntegral count && count < frameItems && size < frameBytes)+          then flush port sq acc count >> loop (sq + 1)+          else do+            -- the items before a bad line go out as read; the bad one never does+            next <- rdTry rd `catch` \(e :: BadLine) -> flush port sq acc count >> throwIO e+            case next of+              Got (JsonItem v n) -> gather port sq (v : acc) (count + 1) (size + n)+              Got (BytesItem _) -> flush port sq acc count >> loop (sq + 1)   -- never: one kind a source+              NotYet -> flush port sq acc count >> loop (sq + 1)+              Ended -> do+                flush port sq acc count+                end port (sq + 1)+      flush port sq acc count = do+        stillOpen <- open'+        when stillOpen $ do+          sent sq count+          ipItems port sq (reverse acc)+      end port sq = do+        stillOpen <- open'+        when stillOpen $ do+          ipEnd port sq+          atomically (writeTVar (fsEnded st) True)+      open' = atomically $ (&&) <$> (not <$> readTVar (fsDone st)) <*> (not . isJust <$> readTVar (fsClosed st))+      -- counted as it goes out (before the send: an ack can only follow+      -- it, and a frame whose send failed may have gone, so it is unacked)+      sent sq count = atomically $ do+        modifyTVar' (fsCredit st) (subtract (fromIntegral count))+        total <- (+ fromIntegral count) <$> readTVar (fsItems st)+        writeTVar (fsItems st) total+        modifyTVar' (fsFrames st) (+ 1)+        modifyTVar' (fsThrough st) (Map.insert (toInteger sq) total)+  loop (0 :: Word64)++tallyOf :: FeedState -> IO Tally+tallyOf st = atomically $ do+  items <- readTVar (fsItems st)+  frames <- readTVar (fsFrames st)+  acked <- readTVar (fsAcked st)+  ackedItems <- readTVar (fsAckedItems st)+  credit <- readTVar (fsCredit st)+  ended <- readTVar (fsEnded st)+  closed <- readTVar (fsClosed st)+  pure Tally { tItems = items, tFrames = frames, tAcked = acked+             , tUnacked = max 0 (items - ackedItems)+             , tCredit = credit, tEnded = ended, tClosed = closed }++-- | @--progress@: the tally on stderr once a second.+tick :: FeedState -> IO ()+tick st = loop+  where+    loop = do+      threadDelay 1000000+      t <- tallyOf st+      TIO.hPutStrLn stderr (renderTally t <> " · credit " <> commas (tCredit t))+      hFlush stderr+      loop++-- | @input: sent 1,204 items in 1,204 frames · acked through seq 1,179 · 25 unacked@+renderTally :: Tally -> Text+renderTally t = "input: sent " <> commas (tItems t) <> " item" <> plural (tItems t)+  <> " in " <> commas (tFrames t) <> " frame" <> plural (tFrames t)+  <> " · " <> maybe "nothing acked" (\a -> "acked through seq " <> commas a) (tAcked t)+  <> " · " <> commas (tUnacked t) <> " unacked"+  where plural n = if n == 1 then "" else "s"++commas :: Integer -> Text+commas n+  | n < 0 = "-" <> commas (negate n)+  | otherwise = T.reverse (T.intercalate "," (T.chunksOf 3 (T.reverse (T.pack (show n)))))++-- | A typed close of the input half on an error item: its @error_kind@ and+-- the frame it names.+inputRefusal :: PlexusStreamItem -> Maybe (Text, Maybe Integer)+inputRefusal = \case+  StreamErrorWith _ _ err _ _ beside+    | Just kind <- kindOf err beside, kind `elem` inputKinds -> Just (kind, seqOf err beside)+  _ -> Nothing+  where+    inputKinds = ["past_credit", "invalid_input", "out_of_sequence", "too_much_input"]+    kindOf err beside = case KM.lookup "error_kind" beside of+      Just (String k) -> Just k+      _ -> case err of+        RE.Other k _ -> Just k+        _ -> Nothing+    seqOf err beside = case KM.lookup "seq" beside of+      Just (Number n) -> Just (round n)+      _ -> case err of+        RE.Other _ fields | Just (Number n) <- KM.lookup "seq" fields -> Just (round n)+        _ -> Nothing++-- | The exit a close calls for: @past_credit@ 70 (EX_SOFTWARE: synapse+-- sent past what it was granted — its own bug); @invalid_input@,+-- @out_of_sequence@, @too_much_input@ 65 (EX_DATAERR); an input closed+-- because the sign-in was revoked, signed out or expired 77.+closeExit :: Maybe (Text, Maybe Integer) -> Maybe (Text, Maybe Integer) -> Maybe (Int, Text)+closeExit refusal closed = case (refusal, closed) of+  (Just ("past_credit", sq), _) -> Just (70, "the service says synapse sent past its credit" <> at sq <> " — a bug in synapse")+  (Just (kind, sq), _) -> Just (65, "the service refused the input (" <> kind <> ")" <> at sq)+  (Nothing, Just (reason, _))+    | reason `elem` ["revoked", "signed_out", "expired", "forbidden", "unauthenticated"] ->+        Just (77, "the service closed the input: " <> reason <> " — sign in again (_login)")+  _ -> Nothing+  where+    at = maybe "" (\s -> " at frame " <> commas s)++-- ---------------------------------------------------------------------------+-- A dry run++-- | @-n@: the first input frame the call would send — a JSON one with its+-- items (the subscription @null@: the server names it when it takes the+-- call), a binary one as its header in hex and its length — or @end@ for+-- an empty source. Reads one item of the source.+dryRunFrame :: Text -> ItemKind -> Int -> InputSource -> IO (Either Text Text)+dryRunFrame ns kind chunk src = do+  h <- openSource src+  rd <- newReader kind chunk h+  r <- try (rdNext rd)+  case src of+    FromFile _ -> hClose h `catch` \(_ :: SomeException) -> pure ()+    FromStdin -> pure ()+  pure $ case r of+    Left (BadLine n why) -> Left (lineError n why)+    Right Nothing -> Right (frame "end" [])+    Right (Just (JsonItem v _)) -> Right (frame "input" ["items" .= [v]])+    Right (Just (BytesItem b)) -> Right $+      "binary: " <> hex 0x01 2 <> " " <> hex 0 16 <> " " <> hex 0 16 <> " + " <> commas (toInteger (BS.length b)) <> " bytes"+        <> " ([0x01][subscription u64][seq u64][payload]; the subscription is the call's)"+  where+    -- the subscription null: the server names it when it takes the call+    frame verb extra = TE.decodeUtf8 $ LBS8.toStrict $ encode $ object+      [ "jsonrpc" .= ("2.0" :: Text), "method" .= (ns <> "." <> verb)+      , "params" .= object (["subscription" .= Null, "seq" .= (0 :: Int)] ++ extra) ]+    hex :: Integer -> Int -> Text+    hex n w = let s = T.pack (showHex n "") in T.replicate (w - T.length s) "0" <> s++-- | How a bad line is said.+lineError :: Int -> Text -> Text+lineError n why = "input line " <> commas (toInteger n) <> " is not an item: " <> why
src/Synapse/Reconnect.hs view
@@ -90,7 +90,7 @@ import System.IO.Unsafe (unsafePerformIO) import System.Posix.Process (exitImmediately) -import Plexus.Schema.Recursive (MethodSchema(..))+import Plexus.Schema.Recursive (Cardinality(..), MethodSchema(..)) import qualified Plexus.Types as PT  -- ---------------------------------------------------------------------------@@ -152,7 +152,7 @@ -- and anything bidirectional — is a write, never sent twice. callShape :: MethodSchema -> CallShape callShape m = CallShape-  { csStream = methodStreaming m+  { csStream = methodCardinality m == CardinalityMany   , csRead   = not (methodBidirectional m) && isRead   , csSince  = takesSince (methodParams m)   }
src/Synapse/Schema/Types.hs view
@@ -1,4 +1,5 @@ {-# LANGUAGE PatternSynonyms #-}+{-# LANGUAGE ViewPatterns #-}  -- | Core types for Synapse --@@ -31,6 +32,8 @@   , pattern ServiceData   , pattern ServiceProgress   , pattern ServiceError+  , serviceErrorMessage+  , jsonLine   , pattern ServiceDone   , pattern ServiceGuidance   , pattern ServiceRequest@@ -43,11 +46,17 @@   , SelectOption(..)   ) where -import Data.Aeson (Value)+import Data.Aeson (Value(..), encode, object, toJSON, (.=))+import qualified Data.Aeson.KeyMap as KM+import qualified Data.ByteString.Lazy as LBS import Data.Text (Text)+import qualified Data.Text.Encoding as TE import Data.Int (Int64)+import Data.Scientific (toRealFloat) import GHC.Generics (Generic) +import Plexus.RemoteError (renderRemoteError)+ -- Re-export from plexus-protocol import Plexus.Schema.Recursive   ( PluginSchema(..)@@ -71,6 +80,8 @@   , Response(..)   , StandardResponse   , SelectOption(..)+  , StreamMetadata(..)+  , itemErrorMessage   )  -- | A path through the plugin tree (sequence of namespace segments)@@ -112,13 +123,60 @@ pattern ServiceData :: Text -> Provenance -> Text -> Value -> ServiceStreamItem pattern ServiceData hash prov contentType content = StreamData hash prov contentType content --- | Pattern synonym for progress events+-- | Pattern synonym for progress events: a message and a percentage.+--+--   Matching only (plexus 0.7: a progress frame carries the method's typed+--   progress value). The default progress type, @Note@, gives its+--   @message@ and @percentage@; any other value gives its JSON as the+--   message. RETURNS-14 renders typed progress properly. pattern ServiceProgress :: Text -> Provenance -> Text -> Maybe Double -> ServiceStreamItem-pattern ServiceProgress hash prov msg pct = StreamProgress hash prov msg pct+pattern ServiceProgress hash prov msg pct <- StreamProgress hash prov _ (progressMessage -> (msg, pct)) --- | Pattern synonym for error events+-- | Pattern synonym for error events: the call failure as one line.+--+--   Matching only. The line is the server's own @message@ when the frame+--   has one (as plexus-core writes it on every error frame, and as main read+--   it — RETURNS-36 review), else the typed error's 'renderRemoteError' line. pattern ServiceError :: Text -> Provenance -> Text -> Bool -> ServiceStreamItem-pattern ServiceError hash prov err recoverable = StreamError hash prov err recoverable+pattern ServiceError hash prov err recoverable <- (serviceErrorView -> Just (hash, prov, err, recoverable))++serviceErrorView :: ServiceStreamItem -> Maybe (Text, Provenance, Text, Bool)+serviceErrorView item = case item of+  StreamError hash prov _ rec -> (\m -> (hash, prov, m, rec)) <$> serviceErrorMessage item+  _ -> Nothing++-- | An error item's line: the server's @message@, else the typed error's.+serviceErrorMessage :: ServiceStreamItem -> Maybe Text+serviceErrorMessage item = case item of+  StreamError _ _ err _ -> Just (maybe (renderRemoteError err) id (itemErrorMessage item))+  _ -> Nothing++-- | RETURNS-36 review: an item as @-j@ prints it — as plexus-protocol+--   writes it, but a progress item whose content is the default progress+--   type, a Note (@message@, @percentage@), as main printed one:+--   @{type, metadata, message, percentage}@.+jsonLine :: ServiceStreamItem -> LBS.ByteString+jsonLine item = case item of+  StreamProgress hash prov _ (Object o)+    | Just (String msg) <- KM.lookup "message" o+    , all (`elem` ["message", "percentage"]) (KM.keys o) ->+        encode $ object+          [ "type" .= ("progress" :: Text)+          , "metadata" .= StreamMetadata prov hash 0+          , "message" .= msg+          , "percentage" .= maybe Null id (KM.lookup "percentage" o) ]+  _ -> encode (toJSON item)++-- | A progress value as the 0.6-style message and percentage.+progressMessage :: Value -> (Text, Maybe Double)+progressMessage (Object o)+  | Just (String msg) <- KM.lookup "message" o =+      ( msg+      , case KM.lookup "percentage" o of+          Just (Number n) -> Just (toRealFloat n)+          _ -> Nothing+      )+progressMessage v = (TE.decodeUtf8 (LBS.toStrict (encode v)), Nothing)  -- | Pattern synonym for done events pattern ServiceDone :: Text -> Provenance -> ServiceStreamItem
src/Synapse/SchemaStore.hs view
@@ -1,3 +1,4 @@+{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-}@@ -5,7 +6,7 @@ -- | SYNAPSE-28: what a call needs from a backend's schema, kept on disk so -- a call sends first and checks only on failure. ----- Under @$XDG_CACHE_HOME/synapse/schemas@ (@~/.cache/synapse/schemas@):+-- Under @$XDG_CACHE_HOME/synapse/schemas-4@ (@~/.cache/synapse/schemas-4@; 3.x's @schemas@ is never read): -- -- @ -- routes/CONNECTION-POINT/BACKEND.json      where BACKEND is reached from there@@ -46,6 +47,8 @@      -- * Forgetting   , forget+    -- * A cache misled (SYNAPSE-28; RETURNS-36 review)+  , isStaleItem   ) where  import Control.Exception (SomeException, catch, try)@@ -69,20 +72,34 @@ import Synapse.Endpoint (Endpoint(..), dirName) import Synapse.Identity.Store (writePrivateFile) import Synapse.IR.Types+import Plexus.Auth (Refusal(..))+import Plexus.RemoteError (ParamProblem(..), RemoteError(..))+import Plexus.Types (itemRefusal, pattern StreamError)+import Synapse.Schema.Types (ServiceStreamItem, pattern ServiceError)  -- | One backend's cache, as one agent sees it at one address. newtype Store = Store { storeDir :: FilePath }   deriving (Eq, Show) --- | @$XDG_CACHE_HOME/synapse/schemas@, else @~/.cache/synapse/schemas@.+-- | @$XDG_CACHE_HOME/synapse/schemas-4@, else @~/.cache/synapse/schemas-4@+-- ('cacheLayout'). schemasRoot :: IO FilePath schemasRoot = do   x <- lookupEnv "XDG_CACHE_HOME"   base <- case x of     Just d | not (null d) -> pure d     _ -> (</> ".cache") <$> getHomeDirectory-  pure (base </> "synapse" </> "schemas")+  pure (base </> "synapse" </> ("schemas-" <> show cacheLayout)) +-- | RETURNS-36 review (#10): the layout of what the store keeps. 4.0 keeps+--   plexus 0.8's schemas (a method's cardinality, never 0.6's streaming)+--   and plans over IR 3.0, which a 3.x synapse would misread; so 4.0 keeps+--   them under @schemas-4@, which no 3.x reads, and never reads 3.x's+--   @schemas@. Bump it with any change a synapse of the other layout would+--   misread.+cacheLayout :: Int+cacheLayout = 4+ storeFor :: Endpoint -> Text -> Text -> IO Store storeFor ep agent backend = do   root <- schemasRoot@@ -248,3 +265,32 @@       forM_ ps $ \f -> rm (plans </> f)   where     rm p = removeFile p `catch` \(_ :: SomeException) -> pure ()++-- ============================================================================+-- A cache misled+-- ============================================================================++-- | SYNAPSE-28: an answer that says the cache misled the call — the server+-- refused before the method ran: a refusal only an admin holds (a method+-- admin-only, unlisted), a method or activation not found, a param the+-- server does not take or misses. RETURNS-36 review: by the typed error+-- (a 0.8 server's, or a 0.7.0 frame's read from its fields), and by the+-- server's words — both phrasings, main's ("missing field") and 0.8's+-- ("missing `id`").+isStaleItem :: ServiceStreamItem -> Bool+isStaleItem item = case itemRefusal item of+  Just r -> refusalRoles r == ["admin"]+  Nothing -> case item of+    StreamError _ _ err _ | typedStale err -> True+    ServiceError _ _ msg _ -> any (`T.isInfixOf` msg) staleWords+    _ -> False+  where+    typedStale = \case+      MethodNotFound _ _ -> True+      ActivationNotFound _ -> True+      InvalidParams _ Missing -> True+      InvalidParams _ (Malformed d) -> any (`T.isInfixOf` d) staleWords+      _ -> False+    staleWords =+      [ "Method not found", "Activation not found", "Unknown method", "method not found", "activation not found"+      , "errCode = -32601", "missing field", "missing `", "unknown field", "invalid type", "unknown variant" ]
src/Synapse/Transport.hs view
@@ -8,6 +8,8 @@   ( -- * Schema Fetching     fetchSchema   , fetchSchemaAt+  , fetchServiceIR+  , rootSchemaKnown   , fetchMethodSchema      -- * Method Invocation (collected)@@ -17,6 +19,7 @@     -- * Method Invocation (streaming)   , invokeStreaming   , invokeStreamingWithBidir+  , invokeWithInput      -- * Bidirectional Response   , sendResponse@@ -31,7 +34,8 @@ import qualified Data.Map.Strict as Map import qualified Synapse.SchemaStore as Store import Control.Monad.Reader (asks)-import Data.Aeson (Value)+import Data.Aeson (Value(..), Result(..), fromJSON, object, (.=))+import qualified Data.Aeson.KeyMap as KM import qualified Data.ByteString.Char8 as BS8 import qualified Data.CaseInsensitive as CI import qualified Data.List as List@@ -45,9 +49,16 @@  import Synapse.Schema.Types import Synapse.Monad+import Synapse.IR.Types (IR(..), currentIrVersion) import Synapse.Endpoint (substrateConfig) import qualified Synapse.Log as Log import qualified Synapse.Reconnect as R+import qualified Synapse.Input as In+import Control.Exception (throwIO)+import Control.Monad (forM_)+import qualified Data.Text.IO as TIO+import System.Exit (ExitCode(..), exitWith)+import System.IO (hFlush, stderr)  -- | Fetch the root schema fetchSchema :: SynapseM PluginSchema@@ -147,6 +158,68 @@       liftIO $ modifyIORef' memo (Map.insert path schema)       pure schema +-- | RETURNS-40: the service's own IR of the walk from @path@ — one call,+--   @<backend>.ir@ with the path as its namespace (none: the whole+--   service). @Just (Right ir)@ when it wrote one of this synapse's IR+--   version; @Just (Left why)@ when it refused (a namespace it has not, a+--   schema of its own it could not read — a title collision is no refusal:+--   its walk refuses that activation into the IR, as this one's does);+--   @Nothing@ when it has no @ir@ (every+--   service on the published 0.7.0 line, whose answer is a call failure) or+--   answered anything else — the caller walks the raw schemas then.+fetchServiceIR :: Path -> SynapseM (Maybe (Either Text IR))+fetchServiceIR path = do+  logger <- getLogger+  cfg <- getConfig+  let params = if null path then object [] else object ["namespaces" .= [T.intercalate "." path]]+  -- asked once, never ridden out (R.retrying): a service that cannot be+  -- reached now is the walk's to wait for, which reads what it can from the+  -- run's memo and the cache and rides out the rest+  result <- liftIO $ ST.invokeMethod cfg [] "ir" params+  let answer = case result of+        Left _ -> Nothing+        Right items -> case [ d | PT.StreamData _ _ _ d <- items ] of+          (Object o : _)+            | Just ok <- KM.lookup "ok" o, Success ir <- fromJSON ok, irVersion ir == currentIrVersion -> Just (Right ir)+            | Just (Object e) <- KM.lookup "err" o -> Just (Left (case KM.lookup "message" e of+                Just (String m) -> m+                _ -> "the service wrote no IR"))+          _ -> Nothing+  Log.logDebug logger Log.SubsystemSchema $ "The service's ir at " <> showPath path <> ": "+    <> maybe "none (walking the schemas)" (either ("refused: " <>) (const "used")) answer+  pure answer++-- | RETURNS-40 review #5: the service's own schema, as cheaply as this run+--   has it — the run's memo, else the schema cache on disk, else fetched+--   once, never ridden out (a service that cannot be reached now is the+--   walk's to wait for), and memoized: a walk from the root reads it from+--   there. 'Nothing' when it cannot be had.+rootSchemaKnown :: SynapseM (Maybe PluginSchema)+rootSchemaKnown = do+  memo <- asks seFetched+  known <- liftIO $ Map.lookup [] <$> readIORef memo+  case known of+    Just s -> pure (Just s)+    Nothing -> do+      src <- asks seSchemaSource+      stored <- case src of+        FromServer (Just st) -> liftIO (Store.readSchema st [])+        FromCache st -> liftIO (Store.readSchema st [])+        FromServer Nothing -> pure Nothing+      case stored of+        Just s -> pure (Just s)+        Nothing -> do+          cfg <- getConfig+          fetched <- liftIO (ST.fetchSchemaAt cfg [])+          case fetched of+            Left _ -> pure Nothing+            Right s -> do+              liftIO $ modifyIORef' memo (Map.insert [] s)+              case src of+                FromServer (Just st) -> liftIO (Store.writeSchema st [] s)+                _ -> pure ()+              pure (Just s)+ -- | A path as a message names it. showPath :: Path -> Text showPath [] = "the root"@@ -334,6 +407,89 @@     getPort (PT.ConnectionRefused _ p) _ = p     getPort (PT.ConnectionTimeout _ p) _ = p     getPort _ c = substratePort c++-- | BIDIR-18: a method with an input half, its input fed from the source+-- (spec/10) as the callee's credit allows. Never sent twice: a connection+-- lost once the call went out is said on stderr with what was sent and+-- acknowledged, and the run exits 69 (a call that never left is tried+-- again in the reconnect window, as any). The printer is told whether the+-- input is still untouched (no credit came, so nothing was read): only+-- then may it hold an item back to run the call again. At the end, the+-- tally on stderr; a typed close exits by its kind ('In.closeExit'), a+-- line that is not an item 65.+invokeWithInput+  :: R.CallShape+  -> IO (Maybe WS.Headers)+  -> In.ItemKind -> Int -> Bool -> In.InputSource+  -> Path+  -> Text+  -> Value+  -> (IO Bool -> PT.PlexusStreamItem -> IO ())+  -> SynapseM ()+invokeWithInput shape0 refreshAuth kind chunk progress src namespacePath method params onItem = do+  cfg <- getConfig+  cfgRef <- liftIO $ newIORef cfg+  refusal <- liftIO $ newIORef Nothing+  let shape = shape0 { R.csRead = False, R.csSince = False }+      whereTo = whereOf cfg+      reauth = refreshAuth >>= \case+        Just hs -> True <$ modifyIORef' cfgRef (\c -> c { substrateHeaders = hs })+        Nothing -> pure False+      say t = TIO.hPutStrLn stderr ("synapse: " <> t) >> hFlush stderr+      tally t = TIO.hPutStrLn stderr (In.renderTally t) >> hFlush stderr+      run (half, untouched) = do+        let item i = do+              case In.inputRefusal i of+                Just r -> modifyIORef' refusal (maybe (Just r) Just)+                Nothing -> pure ()+              case i of+                PT.StreamRequest{} -> pure ()   -- an input call answers no request+                _ -> onItem untouched i+            attempt ps subscribed h = do+              c <- readIORef cfgRef+              ST.invokeMethodDuplex c half subscribed namespacePath method ps h+        R.resilientCall whereTo shape attempt reauth params (const (pure ())) item+  (r, fed, t) <- liftIO $ In.withInputFeed kind chunk progress src run+  ending <- liftIO $ either throwIO pure r+  refused <- liftIO $ readIORef refusal+  let lost why = liftIO $ do+        say $ "connection to " <> whereTo <> " lost during the call (" <> why+          <> ") — its input is not sent again (spec/10: no resume)"+        tally t+        exitWith (ExitFailure R.exitUnreachable)+  case (fed, ending) of+    (In.FedBadLine n why, _) -> liftIO $ do+      say $ "input line " <> T.pack (show n) <> " is not an item: " <> why+        <> " — no frame carried it; the call was ended"+      tally t+      exitWith (ExitFailure 65)+    (In.FedFailed why, _) -> liftIO $ do+      say $ "the input could not be read (" <> why <> ") — the call was ended"+      tally t+      exitWith (ExitFailure 74)+    (_, R.WriteUnknown why) -> lost why+    (_, R.Failed e@(PT.ConnectionLost why True))+      | R.isOutage e -> lost why+    (_, R.GaveUp window why)+      | In.tFrames t > 0 || In.tEnded t -> lost why+      | otherwise -> liftIO (R.reportEnding whereTo (R.GaveUp window why)) >> pure ()+    (_, R.OutputClosed) -> liftIO (R.reportEnding whereTo R.OutputClosed) >> pure ()+    (_, R.Failed e) -> do+      liftIO (tally t)+      backend <- asks seBackend+      throwTransportWith TransportContext+        { tcMessage  = transportErrorToText e+        , tcHost     = T.pack (substrateHost cfg)+        , tcPort     = substratePort cfg+        , tcBackend  = backend+        , tcPath     = namespacePath ++ [method]+        , tcCategory = transportErrorToCategory e+        }+    (_, R.Finished) -> liftIO $ do+      tally t+      forM_ (In.closeExit refused (In.tClosed t)) $ \(code, why) -> do+        say why+        exitWith (ExitFailure code)  -- | SYNAPSE-20: @host:port@, for what synapse says about an outage -- (SCAFFOLD-21: the URL, for an address behind TLS or on a path).
test/AuthSpec.hs view
@@ -1,6 +1,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE LambdaCase #-}+{-# LANGUAGE PatternSynonyms #-}  -- | AUTH-8: synapse shows each caller its own server. --@@ -35,6 +36,7 @@ import Test.Hspec  import Plexus.Auth+import Plexus.RemoteError (DenyReason(..), RemoteError(..), renderRemoteError) import Plexus.Schema.Recursive (ChildSummary(..), MethodSchema(..), PluginSchema(..), MethodRole(..), hasAuthColumns) import Plexus.Types (PlexusStreamItem(..), itemRefusal) import Synapse.Endpoint (plainAt)@@ -43,9 +45,12 @@ import Synapse.Auth.Loopback import Synapse.Identity.Proof (generateSeed) import qualified Synapse.Identity.Session as Session+import qualified Synapse.SchemaStore as Store+import Synapse.Schema.Types (jsonLine, pattern ServiceError) import qualified Synapse.Log as Log  import MockService+import Plexus07 (methodWithContract, withContract)  main :: IO () main = do@@ -62,8 +67,10 @@ -- Fixtures -- ============================================================================ +-- | A fixture as plexus 0.7 states it (RETURNS-36: each method's+-- cardinality and returns; 'withContract'), parsed. parse :: A.FromJSON a => Value -> a-parse v = case fromJSON v of+parse v0 = let v = withContract v0 in case fromJSON v of   Success x -> x   Error e -> error e @@ -99,12 +106,17 @@   , whoName = Just "worker-1", whoAgent = Just "worker-1"   , whoMemberships = [("editor", "MENGER")], whoCeiling = ["editor@MENGER-12"] } --- AUTH-2's refusal, as the subscription notification's result carries it+-- AUTH-2's refusal, as the subscription notification's result carries it:+-- plexus 0.7's typed error (RETURNS-36: a missing grant is a forbidden+-- RemoteError) with AUTH-2's own fields beside it refusalItem :: Value-refusalItem = [aesonQQ|{"type":"error","error_kind":"forbidden",+refusalItem = [aesonQQ|{"type":"error","recoverable":false,+  "message":"needs tickets.set_status on IDENTITY","code":"-32003",+  "error":{"kind":"forbidden","reason":"missing_grant","grant":"tickets.set_status","scope":"IDENTITY",+           "roles":["editor","admin"],"held_on":["MENGER"]},+  "error_kind":"forbidden",   "grant":"tickets.set_status","scope":"IDENTITY",   "roles":["editor","admin"],"held_on":["MENGER"],-  "message":"needs tickets.set_status on IDENTITY",   "metadata":{"provenance":["menger"],"plexus_hash":"h","timestamp":0}}|]  logger :: IO Log.Logger@@ -139,7 +151,8 @@             "children":[{"namespace":"auth","methods":[{"name":"providers","public":true},{"name":"login","public":true}]}]}|] :: PluginSchema       map csNamespace (maybe [] id (psChildren root)) `shouldBe` ["auth"]       psVersion root `shouldBe` ""-      let m = parse [aesonQQ|{"name":"login","public":true}|] :: MethodSchema+      -- RETURNS-36: a 0.7 method states its contract even in the anonymous view+      let m = parse (methodWithContract [aesonQQ|{"name":"login","public":true}|]) :: MethodSchema       (methodName m, methodPublic m, methodDescription m, methodRole m) `shouldBe` ("login", True, "", MethodRoleRpc)     it "writes the new fields back only when present" $ do       let v = toJSON (methodNamed "set_status" todaysTickets)@@ -174,18 +187,30 @@         , refusalRoles = ["editor", "admin"], refusalHeldOn = ["MENGER"], refusalCeiling = []         , refusalMessage = "needs tickets.set_status on IDENTITY" }       case item of-        StreamError _ _ msg _ -> msg `shouldBe` "needs tickets.set_status on IDENTITY"+        StreamError _ _ err _ -> do+          err `shouldBe` Forbidden (MissingGrant "tickets.set_status" "IDENTITY" ["editor", "admin"] ["MENGER"])+          renderRemoteError err `shouldBe` "forbidden: needs `tickets.set_status` on `IDENTITY`"         _ -> expectationFailure "a refusal is still an error item"+      -- the review (RETURNS-36 #1): callers read the server's line, as main did+      case item of+        ServiceError _ _ msg _ -> msg `shouldBe` "needs tickets.set_status on IDENTITY"+        _ -> expectationFailure "a refusal is still an error item"+    it "the typed error alone names the same refusal" $ do+      let typed = parse [aesonQQ|{"type":"error","recoverable":false,+                    "error":{"kind":"forbidden","reason":"missing_grant","grant":"tickets.set_status","scope":"IDENTITY",+                             "roles":["editor","admin"],"held_on":["MENGER"]},+                    "metadata":{"provenance":["menger"],"plexus_hash":"h","timestamp":0}}|] :: PlexusStreamItem+      itemRefusal typed `shouldBe` itemRefusal (parse refusalItem :: PlexusStreamItem)     it "parses as written in the ticket, without metadata" $ do       let bare = parse [aesonQQ|{"type":"error","error_kind":"forbidden","grant":"tickets.set_status",                                  "scope":"IDENTITY","roles":["editor","admin"],"held_on":["MENGER"],-                                 "message":"needs tickets.set_status on IDENTITY"}|] :: PlexusStreamItem+                                 "error":{"kind":"forbidden","reason":"missing_grant","grant":"tickets.set_status","scope":"IDENTITY"}}|] :: PlexusStreamItem       fmap refusalGrant (itemRefusal bare) `shouldBe` Just (Just "tickets.set_status")     it "any other error has no refusal, and builds as before" $ do-      let plain = parse [aesonQQ|{"type":"error","message":"Activation not found: auth",+      let plain = parse [aesonQQ|{"type":"error","error":{"kind":"activation_not_found","activation":"auth"},                     "metadata":{"provenance":["menger"],"plexus_hash":"h","timestamp":0},"recoverable":false}|]       itemRefusal plain `shouldBe` Nothing-      itemRefusal (StreamError "h" (parse [aesonQQ|["m"]|]) "boom" False) `shouldBe` Nothing+      itemRefusal (StreamError "h" (parse [aesonQQ|["m"]|]) (Execution "boom") False) `shouldBe` Nothing     it "keeps a refusal's fields when written back" $ do       let item = parse refusalItem :: PlexusStreamItem       fmap itemRefusal (decode (A.encode item)) `shouldBe` Just (itemRefusal item)@@ -196,6 +221,76 @@       fmap refusalMessage (itemRefusal d) `shouldBe` Just "needs tickets.create on MENGER"       parseRefusal [aesonQQ|{"error_kind":"forbidden"}|] `shouldBe` Just (Refusal Nothing Nothing [] [] [] "forbidden")       parseRefusal [aesonQQ|{"error_kind":"invalid_params","message":"x"}|] `shouldBe` Nothing++  -- the review (RETURNS-36 #1, #7): what synapse reads off an error frame is+  -- the server's message; an ended session is the typed unauthenticated+  -- with the server's reason (main's text otherwise); a cache misled is a+  -- not-found or a param the server does not take, typed or in its words;+  -- -j prints a Note progress as main did+  describe "the server's line (RETURNS-36 review)" $ do+    let meta = [aesonQQ|{"provenance":["menger"],"plexus_hash":"h","timestamp":0}|]+        frame08 msg code err kind = parse (A.object+          [ "type" A..= ("error" :: Text), "metadata" A..= meta, "recoverable" A..= False+          , "message" A..= (msg :: Text), "code" A..= (code :: Text), "error" A..= (err :: Value)+          , "error_kind" A..= (kind :: Text) ]) :: PlexusStreamItem+        frame070 msg code = parse (A.object+          [ "type" A..= ("error" :: Text), "metadata" A..= meta, "recoverable" A..= False+          , "message" A..= (msg :: Text), "code" A..= (code :: Text) ]) :: PlexusStreamItem+        signedOut08 = frame08 "Authentication required: you have signed out: sign in again" "-32001"+                        [aesonQQ|{"kind":"unauthenticated"}|] "unauthenticated"+        missingId08 = frame08 "Invalid params: missing field: id" "-32602"+                        [aesonQQ|{"kind":"invalid_params","param":"id","problem":"missing"}|] "invalid_params"+        notFound08 = parse (A.object+          [ "type" A..= ("error" :: Text), "metadata" A..= meta, "recoverable" A..= False+          , "message" A..= ("Method not found: tickets.get" :: Text), "code" A..= ("-32601" :: Text)+          , "error" A..= [aesonQQ|{"kind":"method_not_found","activation":"tickets","method":"get"}|] ]) :: PlexusStreamItem+    it "ServiceError gives the server's message" $ do+      case signedOut08 of+        ServiceError _ _ m _ -> m `shouldBe` "Authentication required: you have signed out: sign in again"+        _ -> expectationFailure "an error"+      case frame070 "boom" "-32000" of+        ServiceError _ _ m _ -> m `shouldBe` "boom"+        _ -> expectationFailure "an error"+    it "a frame without message: the typed error's line, an unknown kind's too" $ do+      let bare err = parse (A.object [ "type" A..= ("error" :: Text), "metadata" A..= meta, "recoverable" A..= False+                                     , "error" A..= (err :: Value) ]) :: PlexusStreamItem+      case bare [aesonQQ|{"kind":"unauthenticated"}|] of+        ServiceError _ _ m _ -> m `shouldBe` "unauthenticated: credentials required"+        _ -> expectationFailure "an error"+      case bare [aesonQQ|{"kind":"too_many_writes","retry_after_ms":250,"message":"slow down"}|] of+        ServiceError _ _ m _ -> m `shouldBe` "too_many_writes: slow down"+        _ -> expectationFailure "an error"+      Store.isStaleItem (bare [aesonQQ|{"kind":"too_many_connections","max":64}|]) `shouldBe` False+    it "a signed-out or expired session is ended, 0.8 or 0.7.0" $ do+      Session.isSessionEndedItem signedOut08 `shouldBe` True+      Session.isSessionEndedItem (frame08 "Authentication required: your sign-in has expired: sign in again" "-32001"+                                    [aesonQQ|{"kind":"unauthenticated"}|] "unauthenticated") `shouldBe` True+      Session.isSessionEndedItem (frame070 "Authentication required: you have signed out: sign in again" "-32001") `shouldBe` True+    -- re-review #6: main's behaviour wins — the text on any error, as main+    -- matched it, beside the typed unauthenticated's reason+    it "an unauthenticated call without that reason is not ended; any error saying it is, as main read it" $ do+      Session.isSessionEndedItem (frame08 "Authentication required: credentials required" "-32001"+                                    [aesonQQ|{"kind":"unauthenticated"}|] "unauthenticated") `shouldBe` False+      Session.isSessionEndedItem (frame08 "you have signed out" "-32000"+                                    [aesonQQ|{"kind":"execution","message":"you have signed out"}|] "") `shouldBe` True+    it "a cache misled: a missing or unknown param, a method not found — 0.8 and 0.7.0" $ do+      Store.isStaleItem missingId08 `shouldBe` True+      Store.isStaleItem notFound08 `shouldBe` True+      Store.isStaleItem (frame070 "Invalid params: missing field: id" "-32602") `shouldBe` True+      Store.isStaleItem (frame070 "Method not found: tickets.get" "-32601") `shouldBe` True+      Store.isStaleItem (frame08 "Invalid params: unknown field `sumary`, expected one of `id`" "-32602"+        [aesonQQ|{"kind":"invalid_params","problem":"malformed","detail":"unknown field `sumary`, expected one of `id`"}|] "invalid_params")+        `shouldBe` True+      Store.isStaleItem (frame08 "Execution error: boom" "-32000" [aesonQQ|{"kind":"execution","message":"boom"}|] "") `shouldBe` False+    it "-j prints a Note progress as main did, other content as content" $ do+      let progress c = parse (A.object [ "type" A..= ("progress" :: Text), "metadata" A..= meta+                                       , "content_type" A..= ("tickets.import" :: Text), "content" A..= (c :: Value) ]) :: PlexusStreamItem+          line = decode . jsonLine :: PlexusStreamItem -> Maybe Value+      line (progress [aesonQQ|{"message":"half","percentage":50.0}|]) `shouldBe` Just [aesonQQ|+        {"type":"progress","metadata":{"provenance":["menger"],"plexus_hash":"h","timestamp":0},"message":"half","percentage":50.0}|]+      line (progress [aesonQQ|{"message":"started"}|]) `shouldBe` Just [aesonQQ|+        {"type":"progress","metadata":{"provenance":["menger"],"plexus_hash":"h","timestamp":0},"message":"started","percentage":null}|]+      line (progress [aesonQQ|{"type":"batch","done":1,"of":3}|]) `shouldBe` Just (toJSON (progress [aesonQQ|{"type":"batch","done":1,"of":3}|]))    describe "the anonymous page" $ do     it "renders the ticket's transcript" $ do
test/BidirIntegrationSpec.hs view
@@ -97,7 +97,7 @@   describe "Bidirectional type inference" $ do     it "mdBidirType is Nothing for non-bidirectional methods" $ do       -- Find methods that are NOT streaming and likely not bidirectional-      let nonStreamingMethods = filter (not . mdStreaming) (Map.elems $ irMethods ir)+      let nonStreamingMethods = filter ((/= CardinalityMany) . mdCardinality) (Map.elems $ irMethods ir)       -- Check a sample (if any exist)       case take 1 nonStreamingMethods of         [] -> pending  -- No non-streaming methods to test@@ -170,7 +170,7 @@     it "bidirectional methods are streaming (typically)" $ do       let bidirMethods = filter (isJust . mdBidirType) (Map.elems $ irMethods ir)       forM_ bidirMethods $ \method ->-        if mdStreaming method+        if mdCardinality method == CardinalityMany           then pure ()  -- Expected - bidir usually requires streaming           else putStrLn $ "  Note: Bidirectional method " <> T.unpack (mdFullPath method) <>                          " is NOT streaming (unusual but valid)"@@ -232,7 +232,7 @@     it "reports bidirectional coverage statistics" $ do       let allMethods = Map.elems $ irMethods ir       let bidirMethods = filter (isJust . mdBidirType) allMethods-      let streamingMethods = filter mdStreaming allMethods+      let streamingMethods = filter ((== CardinalityMany) . mdCardinality) allMethods        putStrLn "\n  Bidirectional Statistics:"       putStrLn $ "    Total methods:        " <> show (length allMethods)@@ -265,4 +265,4 @@         mdBidirType method `shouldSatisfy` (\case { Just RefJson -> True; _ -> False })        it (T.unpack methodPath <> " is a streaming method") $-        mdStreaming method `shouldBe` True+        mdCardinality method `shouldBe` CardinalityMany
test/DeprecationSpec.hs view
@@ -26,7 +26,8 @@ import Test.Hspec  import Plexus.Schema.Recursive-  ( DeprecationInfo(..)+  ( Cardinality(..)+  , DeprecationInfo(..)   , MethodRole(..)   , MethodSchema(..)   , ParamSchema(..)@@ -46,7 +47,7 @@   , formatMethodWarningLine   , resetDeprecationStateForTesting   )-import Synapse.IR.Builder (extractMethodDef)+import Synapse.IR.Builder (extractMethodDef, renderIRBuildError) import Synapse.IR.Types (IR(..), emptyIR)  -- ---------------------------------------------------------------------------@@ -59,9 +60,12 @@   , methodDescription    = "Relocate a document."   , methodHash           = "hash-move-doc"   , methodParams         = Nothing-  , methodReturns        = Nothing-  , methodStreaming      = False+  , methodReturns        = object []+  , methodError          = Nothing+  , methodProgress       = Nothing+  , methodCardinality    = CardinalityOne   , methodBidirectional  = False+  , methodHttpMethod     = "POST"   , methodRequestType    = Nothing   , methodResponseType   = Nothing   , methodDeprecation    = Nothing@@ -88,11 +92,12 @@   , psVersion         = "1.0"   , psDescription     = "Document activation"   , psLongDescription = Nothing+  , psSelfHash        = Nothing   , psHash            = "hash-docs"   , psMethods         = [deprecatedMethod, plainMethod]   , psChildren        = Nothing   , psDeprecation     = Nothing-  , psRoles = Nothing, psView = Nothing+  , psRequest = Nothing, psRoles = Nothing, psView = Nothing   }  deprecatedPlugin :: PluginSchema@@ -167,7 +172,8 @@ --   'pdDeprecation' lives, lifted there by 'extractMethodDef'). helpFixture :: (IR, T.Text) helpFixture =-  let (_types, mdef) = extractMethodDef "docs" "docs" methodWithDeprecatedParam+  let mdef = either (error . T.unpack . renderIRBuildError) snd+               (extractMethodDef "docs" "docs" methodWithDeprecatedParam)       fullPath       = "docs.relocate"       ir = emptyIR { irMethods = Map.singleton fullPath mdef }   in (ir, renderMethodHelp ir mdef)
test/FindSpec.hs view
@@ -101,8 +101,8 @@ method :: Text -> Text -> Text -> MethodDef method ns name desc = MethodDef   { mdName = name, mdFullPath = ns <> "." <> name, mdNamespace = ns-  , mdDescription = Just desc, mdStreaming = False, mdParams = []-  , mdReturns = RefUnknown, mdBidirType = Nothing, mdBidirResponseType = Nothing+  , mdDescription = Just desc, mdParams = []+  , mdReturns = RefUnknown, mdError = Nothing, mdProgress = Nothing, mdCardinality = CardinalityOne, mdBidirType = Nothing, mdBidirResponseType = Nothing   , mdBidirResponseSchema = Nothing, mdRole = MethodRoleRpc   , mdGrant = Nothing, mdTarget = Nothing, mdLongDescription = Nothing   , mdInjected = [], mdInput = Nothing
test/HoldSpec.hs view
@@ -1,3 +1,4 @@+{-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} @@ -15,26 +16,45 @@ --   exactly one; a method the service renamed falls back to the probe and the --   fetch, then answers; a method the service no longer has is refused after --   the fallback, as it always was.+-- * SYNAPSE-40, the built synapse again: one holder process for the user,+--   however many addresses and agents call, a connection for each (address,+--   agent), never one shared by two agents; a connection whose server+--   stopped is gone within 10 s while the rest are kept; the cap closes the+--   longest idle first; and a loop of calls against short-lived servers+--   (@SYNAPSE40_LOOP@, 500) runs through one holder that holds nothing 10 s+--   after the last server stops, its memory flat. What the holder holds is+--   asked of it on its socket (@{"status":true}@); every holder a test+--   started is stopped by pid when the test ends. module Main (main) where  import Control.Concurrent (forkIO, threadDelay) import Control.Concurrent.Async (mapConcurrently, async, cancel)-import Control.Exception (SomeException, try, fromException)-import Control.Monad (forM_, replicateM_, void)+import Control.Exception (SomeException, bracket, finally, try, fromException)+import Control.Monad (filterM, forM, forM_, forever, replicateM_, void, when)+import Data.Char (isDigit)+import Data.List (isInfixOf, isPrefixOf, isSuffixOf, nub, sort) import Data.Maybe (isJust)-import Data.Aeson (Value(..))+import Data.Aeson (Value(..), decodeStrict) import qualified Data.Aeson.KeyMap as KM+import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as BS8 import Data.IORef import qualified Data.Text as T-import System.Directory (doesFileExist, createDirectoryIfMissing, findExecutable)-import System.Environment (getEnvironment)+import qualified Data.Vector as V+import GHC.Clock (getMonotonicTime)+import qualified Network.Socket as N+import qualified Network.Socket.ByteString as NB+import System.Directory (doesDirectoryExist, doesFileExist, createDirectoryIfMissing, findExecutable, getSymbolicLinkTarget, listDirectory)+import System.Environment (getEnvironment, lookupEnv) import System.Exit (ExitCode(..))-import System.FilePath ((</>))+import System.FilePath ((</>), takeFileName) import System.IO.Temp (withSystemTempDirectory)+import System.Posix.Signals (signalProcess, sigKILL, sigTERM) import System.Process (CreateProcess(..), proc, readCreateProcessWithExitCode) import Test.Hspec  import Plexus.Client (SubstrateConfig(..), LostConnection(..), defaultConfig)+import Plexus.Input (InputHalf(..), InputPort(..), InputSignal(..)) import Plexus.Types (PlexusStreamItem(..)) import qualified Synapse.Hold as Hold @@ -84,10 +104,10 @@     it "serves many runs' requests on one connection" $       withSystemTempDirectory "hold" $ \dir -> withMockService todaysService $ \port -> do         let sock = dir </> "h.sock"-        _ <- forkIO $ Hold.runHolder (Hold.HoldOpts sock 30) `catchAll` const (pure ())+        _ <- forkIO $ Hold.runHolder (Hold.holdOpts sock 30) `catchAll` const (pure ())         waitFor (doesFileExist sock)         before <- readIORef connectionsAccepted-        let call = collect (Hold.holderCall sock (serviceAt port)) "menger.call" (getTicket "MENGER-12")+        let call = collect (Hold.holderCall sock "a1" (serviceAt port)) "menger.call" (getTicket "MENGER-12")         replicateM_ 3 (call >>= (`shouldSatisfy` any isTicket))         rs <- mapConcurrently (const call) [1 .. 20 :: Int]         all (any isTicket) rs `shouldBe` True@@ -97,21 +117,21 @@     it "relays each item as the service sent it, and the end" $       withSystemTempDirectory "hold" $ \dir -> withMockService todaysService $ \port -> do         let sock = dir </> "h.sock"-        _ <- forkIO $ Hold.runHolder (Hold.HoldOpts sock 30) `catchAll` const (pure ())+        _ <- forkIO $ Hold.runHolder (Hold.holdOpts sock 30) `catchAll` const (pure ())         waitFor (doesFileExist sock)         direct <- Hold.newMux         viaMux <- collect (Hold.muxCall direct (serviceAt port)) "menger.call" (getTicket "MENGER-12")-        viaHolder <- collect (Hold.holderCall sock (serviceAt port)) "menger.call" (getTicket "MENGER-12")+        viaHolder <- collect (Hold.holderCall sock "a1" (serviceAt port)) "menger.call" (getTicket "MENGER-12")         viaHolder `shouldBe` viaMux      it "ends the call of a run that goes away mid-stream" $       withSystemTempDirectory "hold" $ \dir -> withMockService todaysService $ \port -> do         writeIORef unsubscribes []         let sock = dir </> "h.sock"-        _ <- forkIO $ Hold.runHolder (Hold.HoldOpts sock 30) `catchAll` const (pure ())+        _ <- forkIO $ Hold.runHolder (Hold.holdOpts sock 30) `catchAll` const (pure ())         waitFor (doesFileExist sock)         got <- newEmptyFlag-        a <- async $ Hold.holderCall sock (serviceAt port) (pure ()) "menger.call" watch (\_ -> setFlag got)+        a <- async $ Hold.holderCall sock "a1" (serviceAt port) (pure ()) "menger.call" watch (\_ -> setFlag got)         waitFlag got         cancel a         waitFor ((== 1) . length <$> readIORef unsubscribes)@@ -119,9 +139,9 @@     it "says a dial it could not make as connect does, so the run reads an outage" $       withSystemTempDirectory "hold" $ \dir -> do         let sock = dir </> "h.sock"-        _ <- forkIO $ Hold.runHolder (Hold.HoldOpts sock 30) `catchAll` const (pure ())+        _ <- forkIO $ Hold.runHolder (Hold.holdOpts sock 30) `catchAll` const (pure ())         waitFor (doesFileExist sock)-        r <- try (collect (Hold.holderCall sock (serviceAt 1)) "menger.call" (getTicket "X"))+        r <- try (collect (Hold.holderCall sock "a1" (serviceAt 1)) "menger.call" (getTicket "X"))         case r of           Left (e :: SomeException) -> do             show e `shouldContain` "Connection failed"@@ -131,47 +151,224 @@     it "carries a bidirectional request and its answer" $       withSystemTempDirectory "hold" $ \dir -> withMockService todaysService $ \port -> do         let sock = dir </> "h.sock"-        _ <- forkIO $ Hold.runHolder (Hold.HoldOpts sock 30) `catchAll` const (pure ())+        _ <- forkIO $ Hold.runHolder (Hold.holdOpts sock 30) `catchAll` const (pure ())         waitFor (doesFileExist sock)         answered <- newIORef Nothing         let onItem i = case i of-              StreamRequest {} -> void $ collect (Hold.holderCall sock (serviceAt port)) "menger.respond"+              StreamRequest {} -> void $ collect (Hold.holderCall sock "a1" (serviceAt port)) "menger.respond"                 (Object (KM.fromList [ ("request_id", String "req-1")                                      , ("response_data", Object (KM.fromList [("type", String "confirmed"), ("value", Bool True)])) ]))               StreamData _ _ "tickets.ask" v -> writeIORef answered (Just v)               _ -> pure ()-        Hold.holderCall sock (serviceAt port) (pure ()) "menger.call" (Object (KM.fromList [("method", String "tickets.ask")])) onItem+        Hold.holderCall sock "a1" (serviceAt port) (pure ()) "menger.call" (Object (KM.fromList [("method", String "tickets.ask")])) onItem         readIORef answered >>= (`shouldSatisfy` isJust) -    it "exits when idle, and takes its socket with it" $+    it "exits when it has held nothing for --hold-for, and takes its socket with it" $       withSystemTempDirectory "hold" $ \dir -> do         let sock = dir </> "h.sock"-        _ <- forkIO $ Hold.runHolder (Hold.HoldOpts sock 1) `catchAll` const (pure ())+        _ <- forkIO $ Hold.runHolder (Hold.holdOpts sock 1) `catchAll` const (pure ())         waitFor (doesFileExist sock)         waitFor (not <$> doesFileExist sock) -  describe "a socket per address and agent" $ do-    it "names the address, the agent and the version" $ do-      p <- Hold.socketPathFor "3.17.1" "wss_app.menger.sh_443_ws" "builder"-      p `shouldContain` "wss_app.menger.sh_443_ws@builder-3.17.1.sock"-      q <- Hold.socketPathFor "3.17.1" "wss_app.menger.sh_443_ws" "pipeline"-      q `shouldNotBe` p-    it "stays short enough for a unix socket" $ do-      p <- Hold.socketPathFor "3.17.1" (replicate 120 'h') "builder"-      length p `shouldSatisfy` (< 108)+    it "closes a connection idle for --hold-for, then exits" $+      withSystemTempDirectory "hold" $ \dir -> withMockService todaysService $ \port -> do+        let sock = dir </> "h.sock"+        _ <- forkIO $ Hold.runHolder (Hold.holdOpts sock 1) `catchAll` const (pure ())+        waitFor (doesFileExist sock)+        collect (Hold.holderCall sock "a1" (serviceAt port)) "menger.call" (getTicket "MENGER-12") >>= (`shouldSatisfy` any isTicket)+        waitFor (not <$> doesFileExist sock) +  describe "the holder, one for every address and agent (SYNAPSE-40)" $ do+    it "keeps a connection per agent at one address, never one for two" $+      withHolder 30 0 $ \sock -> withMockService todaysService $ \port -> do+        c0 <- readIORef connectionsAccepted+        forM_ ["a1", "a2", "a1", "a2"] $ \a ->+          collect (Hold.holderCall sock a (serviceAt port)) "menger.call" (getTicket "MENGER-12") >>= (`shouldSatisfy` any isTicket)+        c1 <- readIORef connectionsAccepted+        (c1 - c0) `shouldBe` 2+        fmap (sort . heldOf) <$> Hold.holderStatus sock `shouldReturn` Just [(port, "a1"), (port, "a2")]++    it "keeps a connection per credential too, as a re-login makes one" $+      withHolder 30 0 $ \sock -> withMockService todaysService $ \port -> do+        c0 <- readIORef connectionsAccepted+        let as tok = (serviceAt port) { substrateHeaders = [("Authorization", "Bearer " <> tok)] }+        forM_ ["t1", "t2", "t1"] $ \tok ->+          collect (Hold.holderCall sock "a1" (as tok)) "menger.call" (getTicket "MENGER-12") >>= (`shouldSatisfy` any isTicket)+        c1 <- readIORef connectionsAccepted+        (c1 - c0) `shouldBe` 2++    it "lets a connection go within two seconds of its server stopping, and keeps the others" $+      withHolder 30 0 $ \sock -> withMockService todaysService $ \live -> do+        let call port = collect (Hold.holderCall sock "a1" (serviceAt port)) "menger.call" (getTicket "MENGER-12")+        call live >>= (`shouldSatisfy` any isTicket)+        gone <- withEphemeralService todaysService $ \port -> do+          call port >>= (`shouldSatisfy` any isTicket)+          fmap (sort . map fst . heldOf) <$> Hold.holderStatus sock `shouldReturn` Just (sort [live, port])+          pure port+        t0 <- getMonotonicTime+        waitFor (maybe False ((== [live]) . map fst . heldOf) <$> Hold.holderStatus sock)+        t1 <- getMonotonicTime+        (t1 - t0) `shouldSatisfy` (< 2.5)+        gone `shouldNotBe` live++    it "dials again, on the next call, an address whose server came back" $+      withHolder 30 0 $ \sock -> do+        port <- withEphemeralService todaysService $ \p -> do+          collect (Hold.holderCall sock "a1" (serviceAt p)) "menger.call" (getTicket "MENGER-12") >>= (`shouldSatisfy` any isTicket)+          pure p+        waitFor (maybe False (null . heldOf) <$> Hold.holderStatus sock)+        withMockServiceOn port $+          collect (Hold.holderCall sock "a1" (serviceAt port)) "menger.call" (getTicket "MENGER-12") >>= (`shouldSatisfy` any isTicket)++    it "closes the connection idle longest when it holds its cap, never one carrying a call" $+      withHolder 30 2 $ \sock -> withServices 4 $ \ps -> case ps of+        [s1, s2, s3, s4] -> do+          let call p = collect (Hold.holderCall sock "a1" (serviceAt p)) "menger.call" (getTicket "MENGER-12") >>= (`shouldSatisfy` any isTicket)+              held = fmap (sort . map fst . heldOf) <$> Hold.holderStatus sock+          -- s1 carries a stream throughout: never the one closed+          got <- newEmptyFlag+          w <- async $ Hold.holderCall sock "a1" (serviceAt s1) (pure ()) "menger.call" watch (\_ -> setFlag got)+          waitFlag got+          call s2+          call s3+          held `shouldReturn` Just (sort [s1, s3])+          call s4+          held `shouldReturn` Just (sort [s1, s4])+          cancel w+        _ -> expectationFailure "four services"++    it "leaves nothing open for a run that went away while its connection was being made" $+      withHolder 30 0 $ \sock -> withMockService todaysService $ \port -> do+        o0 <- readIORef connectionsOpen+        writeIORef upgradeDelay 1000000+        a <- async $ collect (Hold.holderCall sock "a1" (serviceAt port)) "menger.call" (getTicket "MENGER-12")+        threadDelay 300000   -- the dial is under way+        cancel a+        writeIORef upgradeDelay 0+        -- the dial ends a moment on: closed at once, nothing open, nothing held+        threadDelay 1000000+        waitFor ((== o0) <$> readIORef connectionsOpen)+        fmap heldOf <$> Hold.holderStatus sock `shouldReturn` Just []++    it "keeps a connection only as long as its call's --hold-for says" $+      withHolder 60 0 $ \sock -> withMockService todaysService $ \port -> do+        collect (Hold.holderCallAs sock "a1" (Just 1) (const (pure ())) (serviceAt port)) "menger.call" (getTicket "MENGER-12")+          >>= (`shouldSatisfy` any isTicket)+        fmap heldOf <$> Hold.holderStatus sock `shouldReturn` Just [(port, "a1")]+        waitFor (maybe False (null . heldOf) <$> Hold.holderStatus sock)++    it "exits when it holds nothing however often its status is asked" $+      withSystemTempDirectory "hold" $ \dir -> do+        let sock = dir </> "h.sock"+        _ <- forkIO $ Hold.runHolder (Hold.holdOpts sock 1) `catchAll` const (pure ())+        waitFor (doesFileExist sock)+        poller <- async $ forever $ Hold.holderStatus sock >> threadDelay 200000+        waitFor (not <$> doesFileExist sock)+        cancel poller++    it "says what it holds, never a credential" $+      withHolder 30 0 $ \sock -> withMockService todaysService $ \port -> do+        let cfg = (serviceAt port) { substrateHeaders = [("Authorization", "Bearer mgs_s3cret")] }+        collect (Hold.holderCall sock "a1" cfg) "menger.call" (getTicket "MENGER-12") >>= (`shouldSatisfy` any isTicket)+        st <- Hold.holderStatus sock+        fmap heldOf st `shouldBe` Just [(port, "a1")]+        show st `shouldNotContain` "s3cret"++  -- BIDIR-18: a call's input half rides the call's connection — through+  -- the holder (protocol 4: "sig", "in", "bin", "end") and on a run's own+  describe "a call with an input half" $ do+    let carries name route = it ("carries text and binary input frames " <> name) $+          withMockService todaysService $ \port -> do+            writeIORef schemaOverride (Just inputSchemas)+            resetInput defaultInputPlan+            (textItems, textSigs) <- duplexCall (route port) "stream.lines" $ \p -> do+              ipItems p 0 [String "one", String "two"]+              ipItems p 1 [String "three"]+              ipEnd p 2+            any isTaken textItems `shouldBe` True+            textSigs `shouldSatisfy` any (\case { InputCredit 64 -> True; _ -> False })+            textSigs `shouldSatisfy` any (\case { InputAck 1 -> True; _ -> False })+            rs <- readIORef inputReceived+            rs `shouldBe` [ReceivedJson 0 [String "one", String "two"], ReceivedJson 1 [String "three"], ReceivedEnd 2]+            resetInput defaultInputPlan+            (binItems, _) <- duplexCall (route port) "stream.audio" $ \p -> do+              ipBinary p 0 (BS.pack [1, 2, 3])+              ipBinary p 1 (BS.replicate 640 9)+              ipEnd p 2+            any isTaken binItems `shouldBe` True+            rs' <- readIORef inputReceived+            [ (sq, BS.length b) | ReceivedBinary _ sq b <- rs' ] `shouldBe` [(0, 3), (1, 640)]+            [ sq | ReceivedEnd sq <- rs' ] `shouldBe` [2]+            readIORef inputViolations `shouldReturn` []+            writeIORef schemaOverride Nothing+    -- review of BIDIR-18: the holder reads a binary frame's line strictly+    -- and refuses one past 16 MiB, ending the call with why+    forM_ [ ("b 0 20000000", "past the"), ("b 0x5", "cannot read"), ("b 0 5 6", "cannot read") ] $ \(line, says) ->+      it ("refuses the binary frame line " <> show line <> ", ending the call with an error") $+        withHolder 30 0 $ \sock -> withMockService todaysService $ \port -> do+          writeIORef schemaOverride (Just inputSchemas)+          resetInput defaultInputPlan+          s <- N.socket N.AF_UNIX N.Stream N.defaultProtocol+          N.connect s (N.SockAddrUnix sock)+          NB.sendAll s $ BS8.pack $ "{\"agent\":\"a1\",\"input\":true,\"host\":\"127.0.0.1\",\"port\":" <> show port+            <> ",\"path\":\"/ws\",\"tls\":false,\"ca\":[],\"headers\":[],\"method\":\"menger.call\",\"params\":{\"method\":\"stream.audio\"}}\n"+          let readUntil acc = do+                chunk <- NB.recv s 65536+                let acc' = acc <> chunk+                if BS.null chunk || any (BS8.isInfixOf "\"t\":\"error\"") (BS8.lines acc') || any (BS8.isInfixOf "\"t\":\"end\"") (BS8.lines acc')+                  then pure acc' else readUntil acc'+              readSub acc = do+                chunk <- NB.recv s 65536+                let acc' = acc <> chunk+                if BS.null chunk || BS8.isInfixOf "\"t\":\"sub\"" acc' then pure acc' else readSub acc'+          _ <- readSub ""+          NB.sendAll s (BS8.pack (line <> "\n"))+          out <- readUntil ""+          N.close s+          let errs = [ l | l <- BS8.lines out, BS8.isInfixOf "\"t\":\"error\"" l ]+          map BS8.unpack errs `shouldSatisfy` any (says `isInfixOf`)+          rs <- readIORef inputReceived+          [ r | r@ReceivedBinary {} <- rs ] `shouldBe` []+          writeIORef schemaOverride Nothing+    carries "through the holder" $ \port half -> \onSub m ps onItem -> withHolderOnce $ \sock ->+      Hold.holderCallDuplex sock "a1" Nothing (const (pure ())) (serviceAt port) half onSub m ps onItem+    carries "on the run's own connection (--no-hold)" $ \port half -> \onSub m ps onItem -> do+      mux <- Hold.newMux+      Hold.muxCallDuplex mux (serviceAt port) half onSub m ps onItem++  describe "one socket for the user" $ do+    -- RETURNS-36: 4.0 changes the items the holder relays (plexus 0.8's+    -- typed error and progress), so its holder is protocol 3, apart from+    -- 3.17.x's (2)+    it "is holder-4.sock in the runtime directory, whatever the address or agent" $ do+      p <- Hold.holderSocketPath+      takeFileName p `shouldBe` "holder-4.sock"+      Hold.holdProtocol `shouldBe` 4+   describe "the CLI, sending first" $ do     synapse <- runIO (findExecutable "synapse")     case synapse of       Nothing -> it "(the synapse executable is not on PATH)" $ pendingWith "cabal test puts it there"       Just exe -> cliSpec exe +  describe "one holder for the user (SYNAPSE-40)" $ do+    synapse <- runIO (findExecutable "synapse")+    case synapse of+      Nothing -> it "(the synapse executable is not on PATH)" $ pendingWith "cabal test puts it there"+      Just exe -> oneHolderSpec exe+ cliSpec :: FilePath -> Spec cliSpec exe = do   it "is this build" $ do     (_, out, _) <- readCreateProcessWithExitCode (proc exe ["--version"]) ""-    out `shouldStartWith` "3.17"+    out `shouldStartWith` "4.0" +  -- re-review #5: help names the holder's socket as it is+  it "help names holder-4.sock" $ do+    (_, out, err) <- readCreateProcessWithExitCode (proc exe ["--help"]) ""+    (out <> err) `shouldContain` "holder-4.sock"+    (out <> err) `shouldNotContain` "holder-3.sock"+   it "answers cold, fills the cache, and opens one connection for the probe, the schema and the call" $     withRun $ \run _ -> do       c0 <- readIORef connectionsAccepted@@ -259,7 +456,312 @@           run' extra args = readCreateProcessWithExitCode             (proc exe (extra ++ ["--url", "ws://127.0.0.1:" <> show port <> "/ws", "menger"] ++ args)) { env = Just env } ""       writeIORef renamedGet False-      act (run' []) run'+      act (run' []) run' `finally` stopHolders dir++-- | SYNAPSE-40: the built synapse, every run as the user it is, through one+-- holder (each test a home and runtime directory of its own, the holder told+-- to wait 60 s, so what it lets go it lets go by itself).+oneHolderSpec :: FilePath -> Spec+oneHolderSpec exe = do+  it "runs one holder process however many addresses and agents call, with a connection for each" $+    withHome [] $ \dir env -> withServices 3 $ \ps -> do+      forM_ ps $ \p -> forM_ ["a1", "a2"] $ \a -> do+        (code, out, _) <- runIn exe env ["--as", a] p+        code `shouldBe` ExitSuccess+        out `shouldContain` "MENGER-12"+      length <$> holdersUnder dir `shouldReturn` 1+      st <- holderStatusIn dir+      fmap (sort . heldOf) st `shouldBe` Right (sort [ (p, a) | p <- ps, a <- ["a1", "a2"] ])++  it "never lets two agents share a connection to one address" $+    withHome [] $ \dir env -> withMockService todaysService $ \p -> do+      c0 <- readIORef connectionsAccepted+      forM_ ["a1", "a2", "a1", "a2"] $ \a -> do+        (code, _, _) <- runIn exe env ["--as", a] p+        code `shouldBe` ExitSuccess+      c1 <- readIORef connectionsAccepted+      (c1 - c0) `shouldBe` 2+      st <- holderStatusIn dir+      fmap (sort . heldOf) st `shouldBe` Right [(p, "a1"), (p, "a2")]++  it "lets a connection go within 10 s of its server stopping, and keeps the others" $+    withHome [] $ \dir env -> withMockService todaysService $ \live -> do+      (code0, _, _) <- runIn exe env [] live+      code0 `shouldBe` ExitSuccess+      forM_ [1 .. 3 :: Int] $ \_ -> withEphemeralService todaysService $ \p -> do+        (code, out, _) <- runIn exe env [] p+        code `shouldBe` ExitSuccess+        out `shouldContain` "MENGER-12"+      -- all three have stopped: within 10 s the holder holds the live one alone+      st <- waitStatus 10 (\v -> map fst (heldOf v) == [live]) dir+      fmap (map fst . heldOf) st `shouldBe` Right [live]+      length <$> holdersUnder dir `shouldReturn` 1++  it "holds no more than its cap, closing the longest idle first" $+    withHome [("SYNAPSE_HOLD_MAX", "3")] $ \dir env -> withServices 6 $ \ps -> case ps of+      [s1, s2, s3, s4, s5, s6] -> do+        let call p = runIn exe env [] p >>= \(code, _, _) -> code `shouldBe` ExitSuccess+            held = fmap (sort . map fst . heldOf) <$> holderStatusIn dir+        mapM_ call [s1, s2, s3, s4, s5]+        held `shouldReturn` Right (sort [s3, s4, s5])+        call s3   -- s3 is the one used last now+        call s6   -- and s4 the one idle longest+        held `shouldReturn` Right (sort [s3, s5, s6])+      _ -> expectationFailure "six services"++  it "puts its own holder in the place of an older one, never of a newer one" $ do+    (_, v, _) <- readCreateProcessWithExitCode (proc exe ["--version"]) ""+    let ours = takeWhile (/= '\n') v+        sockIn dir = dir </> "run" </> "synapse" </> ("holder-" <> show Hold.holdProtocol <> ".sock")+        versionIn dir = fmap (\st -> case st of { Object o | Just (String x) <- KM.lookup "version" o -> T.unpack x; _ -> "" }) <$> holderStatusIn dir+    -- an older holder (3.16.0, here in this process) is drained and replaced by the run's+    withHome [] $ \dir env -> withMockService todaysService $ \port -> do+      _ <- forkIO $ Hold.runHolder (Hold.holdOpts (sockIn dir) 30) { Hold.hoVersion = "3.16.0" } `catchAll` const (pure ())+      waitFor (doesFileExist (sockIn dir))+      (code, out, _) <- runIn exe env [] port+      (code, "MENGER-12" `isInfixOf` out) `shouldBe` (ExitSuccess, True)+      versionIn dir `shouldReturn` Right ours+      hs <- holdersUnder dir+      length hs `shouldBe` 1+      -- and the next run keeps it: no turns taken+      (code', _, _) <- runIn exe env [] port+      code' `shouldBe` ExitSuccess+      holdersUnder dir `shouldReturn` hs+    -- a newer one (99.0.0) serves the run and stays+    withHome [] $ \dir env -> withMockService todaysService $ \port -> do+      _ <- forkIO $ Hold.runHolder (Hold.holdOpts (sockIn dir) 30) { Hold.hoVersion = "99.0.0" } `catchAll` const (pure ())+      waitFor (doesFileExist (sockIn dir))+      (code, out, _) <- runIn exe env [] port+      (code, "MENGER-12" `isInfixOf` out) `shouldBe` (ExitSuccess, True)+      versionIn dir `shouldReturn` Right "99.0.0"+      holdersUnder dir `shouldReturn` []++  -- RETURNS-36: a 3.x holder (protocol 2, holder-2.sock) is neither used nor+  -- replaced by 4.0: the run holds on a socket of its own protocol, and the+  -- 3.x holder is left as it was, for the 3.x runs+  it "never reuses nor replaces a 3.x holder (protocol 2)" $+    withHome [] $ \dir env -> withMockService todaysService $ \port -> do+      let old = dir </> "run" </> "synapse" </> "holder-2.sock"+      _ <- forkIO $ Hold.runHolder (Hold.holdOpts old 30) { Hold.hoVersion = "3.17.4" } `catchAll` const (pure ())+      waitFor (doesFileExist old)+      (code, out, _) <- runIn exe env [] port+      (code, "MENGER-12" `isInfixOf` out) `shouldBe` (ExitSuccess, True)+      let version = fmap (\st -> case st of { Object o | Just (String x) <- KM.lookup "version" o -> T.unpack x; _ -> "" })+      version <$> askStatus old `shouldReturn` Right "3.17.4"+      fmap heldOf <$> askStatus old `shouldReturn` Right []+      doesFileExist (dir </> "run" </> "synapse" </> ("holder-" <> show Hold.holdProtocol <> ".sock")) `shouldReturn` True++  it "starts its holder in /, without the run's keepalive or secrets" $+    withHome [("PLEXUS_KEEPALIVE", "off"), ("SYNAPSE40_MARK", "kept")] $ \dir env -> withMockService todaysService $ \port -> do+      (code, _, _) <- runIn exe env [] port+      code `shouldBe` ExitSuccess+      hs <- holdersUnder dir+      case hs of+        [h] -> do+          environ <- map BS8.unpack . BS.split 0 <$> BS.readFile ("/proc" </> show h </> "environ")+          getSymbolicLinkTarget ("/proc" </> show h </> "cwd") `shouldReturn` "/"+          any ("PLEXUS_KEEPALIVE=" `isPrefixOf`) environ `shouldBe` False+          ("SYNAPSE40_MARK=kept" `elem` environ) `shouldBe` True+        other -> expectationFailure ("holders: " <> show other)++  it "keeps a live connection for about 100 kB (what its cap is set by)" $ do+    n <- maybe 100 read <$> lookupEnv "SYNAPSE40_HELD"+    withHome [("SYNAPSE_HOLD_MAX", "0"), ("SYNAPSE_HOLD_FOR", "600s")] $ \dir env -> withServices (n + 3) $ \ps -> do+      let call p = runIn exe env [] p >>= \(code, _, _) -> code `shouldBe` ExitSuccess+          (warm, rest) = splitAt 3 ps+      mapM_ call warm+      hs <- holdersUnder dir+      case hs of+        [h] -> do+          r0 <- rssKb h+          mapM_ call rest+          r1 <- rssKb h+          st <- holderStatusIn dir+          fmap (length . heldOf) st `shouldBe` Right (n + 3)+          let per = (r1 - r0) `div` n+          putStrLn $ "      " <> show (n + 3) <> " connections held: " <> show r0 <> " kB with 3, "+            <> show r1 <> " kB with " <> show (n + 3) <> ": about " <> show per <> " kB each"+          per `shouldSatisfy` (< 200)+        other -> expectationFailure ("holders: " <> show other)++  it "runs a loop of calls against short-lived servers through one holder, which holds nothing 10 s on, its memory flat" $ do+    n <- maybe 500 read <$> lookupEnv "SYNAPSE40_LOOP"+    withHome [] $ \dir env -> do+      seen <- newIORef []+      samples <- newIORef []+      t0 <- getMonotonicTime+      forM_ [1 .. n :: Int] $ \i -> do+        withEphemeralService todaysService $ \p -> do+          (code, out, err) <- runIn exe env [] p+          when (code /= ExitSuccess || not ("MENGER-12" `isInfixOf` out)) $+            expectationFailure ("call " <> show i <> ": " <> show code <> " " <> err)+        hs <- holdersUnder dir+        modifyIORef' seen (nub . (hs ++))+        when (i `mod` max 1 (n `div` 10) == 0) $ case hs of+          [h] -> do+            rss <- rssKb h+            st <- holderStatusIn dir+            modifyIORef' samples (++ [(i, rss, either (const (-1)) (length . heldOf) st)])+          _ -> pure ()+      t1 <- getMonotonicTime+      pids <- readIORef seen+      length pids `shouldBe` 1+      st <- waitStatus 10 (null . heldOf) dir+      fmap heldOf st `shouldBe` Right []+      ss <- readIORef samples+      putStrLn $ "      " <> show n <> " calls against " <> show n <> " servers in "+        <> show (round (t1 - t0) :: Int) <> " s; holder " <> show pids+        <> "; (call, RSS kB, connections held): " <> show ss+      case ss of+        (_ : (_, early, _) : _) -> do+          let (_, late, _) = last ss+          -- flat: what it had grown to by a fifth of the way, within 4 MB, at the end+          late `shouldSatisfy` (<= early + 4096)+        _ -> pure ()+      -- never more held than the servers that stopped in its last second or so+      maximum (0 : [ h | (_, _, h) <- ss ]) `shouldSatisfy` (<= 20)++-- | SYNAPSE-40: a home, a cache and a runtime directory of the test's own,+-- and the environment a run is given there (no keychain, a holder that+-- waits 60 s unless told otherwise); every holder a run started there is+-- stopped by pid when the action ends.+withHome :: [(String, String)] -> (FilePath -> [(String, String)] -> IO a) -> IO a+withHome extra act = withSystemTempDirectory "synapse40" $ \dir -> do+  forM_ ["home", "cache", "run"] $ \d -> createDirectoryIfMissing True (dir </> d)+  env0 <- getEnvironment+  let ours = ["HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR", "SYNAPSE_AS", "PLEXUS_SESSION"+             , "SYNAPSE_NO_HOLD", "SYNAPSE_HOLD_FOR", "SYNAPSE_HOLD_MAX"]+      env = [ (k, v) | (k, v) <- env0, k `notElem` ours ]+         ++ [ ("HOME", dir </> "home"), ("XDG_CACHE_HOME", dir </> "cache"), ("XDG_RUNTIME_DIR", dir </> "run")+            , ("PLEXUS_KEYCHAIN", "off") ]+         ++ [ ("SYNAPSE_HOLD_FOR", "60s") | "SYNAPSE_HOLD_FOR" `notElem` map fst extra ]+         ++ extra+  act dir env `finally` stopHolders dir++-- | One run of the built synapse: @tickets get@ at the service on the port.+runIn :: FilePath -> [(String, String)] -> [String] -> Int -> IO (ExitCode, String, String)+runIn exe env extra port = readCreateProcessWithExitCode+  (proc exe (extra ++ ["--url", "ws://127.0.0.1:" <> show port <> "/ws", "menger", "tickets", "get", "--id", "MENGER-12"]))+    { env = Just env } ""++-- | Services on fresh ports, all up for the action.+withServices :: Int -> ([Int] -> IO a) -> IO a+withServices 0 act = act []+withServices k act = withMockService todaysService $ \p -> withServices (k - 1) (act . (p :))++-- | The holder processes (pids) whose socket lies under the directory.+holdersUnder :: FilePath -> IO [Int]+holdersUnder dir = do+  pids <- filter (all isDigit) <$> listDirectory "/proc"+  fmap concat $ forM pids $ \p -> do+    r <- try (BS.readFile ("/proc" </> p </> "cmdline"))+    pure $ case r of+      Left (_ :: SomeException) -> []+      Right bs ->+        let args = map BS8.unpack (BS.split 0 bs)+        in [ read p | "_hold" `elem` args, any (dir `isInfixOf`) args ]++-- | Stop every holder under the directory, by pid: SIGTERM, then SIGKILL+-- for any still there 3 s on.+stopHolders :: FilePath -> IO ()+stopHolders dir = do+  ps <- holdersUnder dir+  forM_ ps $ \p -> void (try (signalProcess sigTERM (fromIntegral p)) :: IO (Either SomeException ()))+  let alive p = doesDirectoryExist ("/proc" </> show p)+      wait (0 :: Int) = pure ()+      wait k = do+        left <- filterM alive ps+        if null left then pure () else threadDelay 50000 >> wait (k - 1)+  wait 60+  left <- holdersUnder dir+  forM_ left $ \p -> void (try (signalProcess sigKILL (fromIntegral p)) :: IO (Either SomeException ()))++-- | A process's resident memory, in kB.+rssKb :: Int -> IO Int+rssKb pid = do+  s <- BS8.unpack <$> BS.readFile ("/proc" </> show pid </> "status")+  pure $ case [ read (words l !! 1) | l <- lines s, "VmRSS:" `isPrefixOf` l ] of+    (kb : _) -> kb+    [] -> 0++-- | What the holder under the directory says it holds: its one socket,+-- asked for its status.+holderStatusIn :: FilePath -> IO (Either String Value)+holderStatusIn dir = do+  let sdir = dir </> "run" </> "synapse"+  r <- try (listDirectory sdir)+  case filter (".sock" `isSuffixOf`) (either (\(_ :: SomeException) -> []) id r) of+    [one] -> askStatus (sdir </> one)+    other -> pure (Left ("not one holder's socket: " <> show other))++-- | The holder's status: @{"status":true}@ sent, a line of JSON back,+-- @{"t":"status", "held": [{"host", "port", "agent", …}, …], …}@.+askStatus :: FilePath -> IO (Either String Value)+askStatus path = do+  r <- try $ bracket (N.socket N.AF_UNIX N.Stream N.defaultProtocol) N.close $ \s -> do+    N.connect s (N.SockAddrUnix path)+    NB.sendAll s "{\"status\":true}\n"+    let go acc = do+          chunk <- NB.recv s 65536+          if BS.null chunk || BS8.elem '\n' chunk then pure (acc <> chunk) else go (acc <> chunk)+    BS8.takeWhile (/= '\n') <$> go BS.empty+  pure $ case r of+    Left (e :: SomeException) -> Left (show e)+    Right line -> case decodeStrict line of+      Just v@(Object o) | KM.lookup "t" o == Just (String "status") -> Right v+      _ -> Left ("not a status: " <> BS8.unpack line)++-- | Ask until the status satisfies the test, or the seconds run out; the+-- last answer.+waitStatus :: Double -> (Value -> Bool) -> FilePath -> IO (Either String Value)+waitStatus secs ok dir = do+  t0 <- getMonotonicTime+  let go = do+        st <- holderStatusIn dir+        now <- getMonotonicTime+        case st of+          Right v | ok v -> pure st+          _ | now - t0 >= secs -> pure st+            | otherwise -> threadDelay 200000 >> go+  go++-- | The connections a status says are held: (port, agent) each.+heldOf :: Value -> [(Int, T.Text)]+heldOf (Object o) | Just (Array hs) <- KM.lookup "held" o =+  [ (round n, a) | Object h <- V.toList hs+                 , Just (Number n) <- [KM.lookup "port" h], Just (String a) <- [KM.lookup "agent" h] ]+heldOf _ = []++-- | A holder in this process for the action: its socket, waiting the+-- seconds, keeping at most the cap (0: none).+withHolder :: Double -> Int -> (FilePath -> IO a) -> IO a+withHolder secs cap act = withSystemTempDirectory "hold" $ \dir -> do+  let sock = dir </> "h.sock"+  _ <- forkIO $ Hold.runHolder (Hold.holdOpts sock secs) { Hold.hoCap = cap } `catchAll` const (pure ())+  waitFor (doesFileExist sock)+  act sock++-- | BIDIR-18: one call with an input half on the route: once the port is+-- handed over, @feed@ sends its frames; the items and the signals, as they+-- came.+duplexCall :: (Maybe InputHalf -> IO () -> T.Text -> Value -> (PlexusStreamItem -> IO ()) -> IO ())+           -> T.Text -> (InputPort -> IO ()) -> IO ([PlexusStreamItem], [InputSignal])+duplexCall route inner feed = do+  items <- newIORef []+  sigs <- newIORef []+  let half = InputHalf+        { ihPort = \p -> void (forkIO (threadDelay 50000 >> feed p))+        , ihSignal = \g -> modifyIORef' sigs (++ [g]) }+  route (Just half) (pure ()) "menger.call" (Object (KM.fromList [("method", String inner)])) (\i -> modifyIORef' items (++ [i]))+  (,) <$> readIORef items <*> readIORef sigs++isTaken :: PlexusStreamItem -> Bool+isTaken (StreamData _ _ _ (Object o)) = KM.lookup "type" o == Just (String "taken")+isTaken _ = False++-- | A holder in this process for one call.+withHolderOnce :: (FilePath -> IO a) -> IO a+withHolderOnce = withHolder 30 0  -- --------------------------------------------------------------------------- 
test/IR12MethodRoleSpec.hs view
@@ -33,7 +33,8 @@ import qualified Data.Map.Strict as Map import Synapse.IR.Builder (extractMethodDef) import Synapse.IR.Types-  ( IR(..)+  ( Cardinality(..)+  , IR(..)   , emptyIR   , MethodDef(..)   , TypeRef(..)@@ -46,9 +47,11 @@   , mdFullPath            = "solar.planet"   , mdNamespace           = "solar"   , mdDescription         = Just "Fetch a planet child by name."-  , mdStreaming           = False   , mdParams              = []   , mdReturns             = RefJson+  , mdError               = Nothing+  , mdProgress            = Nothing+  , mdCardinality         = CardinalityOne   , mdBidirType           = Nothing   , mdBidirResponseType   = Nothing   , mdBidirResponseSchema = Nothing@@ -60,31 +63,34 @@   , mdInput               = Nothing   } --- | Build a JSON @MethodSchema@ with an optional @role@ key. Passing---   'Nothing' produces the pre-IR shape (no @role@ key at all).+-- | Build a 0.7 JSON @MethodSchema@ with an optional @role@ key. Passing+--   'Nothing' produces the pre-IR shape (no @role@ key at all). @returns@+--   and @cardinality@ are required since plexus 0.7. methodSchemaJson :: Maybe Value -> Value methodSchemaJson mRole =   let base =         [ "name"        .= ("planet" :: String)         , "description" .= ("Fetch a planet child by name." :: String)         , "hash"        .= ("h-planet" :: String)+        , "returns"     .= object []+        , "cardinality" .= ("one" :: String)         ]   in object $ case mRole of        Nothing   -> base        Just role -> ("role" .= role) : base --- | A hand-written pre-IR-12 MethodDef JSON blob: same field names as---   the generic derivation produces, minus @mdRole@. Exercising the---   back-compat path of synapse's manual @FromJSON MethodDef@ instance.+-- | A hand-written IR 3.0 MethodDef JSON blob without @mdRole@ (as+--   pre-IR-12 producers wrote it). Exercising the default of synapse's+--   manual @FromJSON MethodDef@ instance. preIrMethodDefJson :: Value preIrMethodDefJson = object   [ "mdName"        .= ("planet" :: String)   , "mdFullPath"    .= ("solar.planet" :: String)   , "mdNamespace"   .= ("solar" :: String)   , "mdDescription" .= Aeson.Null-  , "mdStreaming"   .= False   , "mdParams"      .= ([] :: [Value])   , "mdReturns"     .= object [ "tag" .= ("RefAny" :: String) ]+  , "mdCardinality" .= ("one" :: String)   ]  main :: IO ()@@ -184,10 +190,11 @@           [ "name" .= ("set_status" :: String)           , "description" .= ("Change a ticket's status" :: String)           , "hash" .= ("h" :: String)+          , "returns" .= object [], "cardinality" .= ("one" :: String)           ] <> extra         lifted j = case Aeson.fromJSON j :: Aeson.Result MethodSchema of           Aeson.Error e -> error e-          Aeson.Success ms -> snd (extractMethodDef "tickets" "tickets" ms)+          Aeson.Success ms -> either (error . show) snd (extractMethodDef "tickets" "tickets" ms)         keysOf v = case v of           Object o -> o           _ -> KM.empty
+ test/IR3Spec.hs view
@@ -0,0 +1,577 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE QuasiQuotes #-}++-- | IR 3.0 (RETURNS-13): a method's contract — item, error and progress+--   types and its cardinality — read from its plexus 0.7 schema, never+--   guessed; no two shapes under one title.+--+--   * The golden test builds IR from RETURNS-7's conformance fixture+--     (@../plexus-macros/fixtures/conformance/expected/schema.json@, by+--     sibling path) with 'buildIRFromSchema' and compares it, byte for byte,+--     with the checked-in @expected/ir.json@ beside it. Run with+--     @SYNAPSE_GOLDEN_ACCEPT=1@ to (re)write that file instead.+--   * Then one test per row of RETURNS-13's Required behavior table.+--   * RETURNS-40: the IR of a whole service, built from every schema the+--     walk reads ('buildIRFromSchemas') — the fixture+--     @../plexus-macros/fixtures/ir-service/expected/@: @schemas.json@ in,+--     @ir.json@ (the service), @ir.alpha.json@ (its @alpha@ subtree) and+--     @clash.ir.json@ (a title collision refuses its activation) and+--     @clash.one.error.txt@ (the one-plugin entry still fails) out. The service writes+--     its own IR (@<service>.ir@, plexus-core), held equal to these files.+--     @SYNAPSE_GOLDEN_ACCEPT=1@ rewrites them too.+module Main where++import Control.Monad (forM_, unless)+import Data.Aeson (Value(..), eitherDecode, object, (.=))+import qualified Data.Aeson as Aeson+import Data.Aeson.Encode.Pretty (Config(..), Indent(..), defConfig, encodePretty')+import qualified Data.Aeson.KeyMap as KM+import Data.Aeson.QQ.Simple (aesonQQ)+import qualified Data.ByteString.Lazy as LBS+import Data.List (isInfixOf)+import qualified Data.Map.Strict as Map+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import System.Environment (lookupEnv)+import System.FilePath ((</>))+import Test.Hspec++import Plexus.Schema.Recursive (MethodSchema(..), PluginSchema(..))+import Synapse.IR.Builder+  ( IRBuildError(..)+  , TypeSource(..)+  , buildIRFromSchema+  , buildIRFromSchemas+  , renderIRBuildError+  )+import Synapse.IR.Types++-- ============================================================================+-- The fixture+-- ============================================================================++-- | The fixture's directory: by sibling path, or wherever+--   @CONFORMANCE_FIXTURE_DIR@ says (a copy, to check an ir.json before it+--   is committed beside the fixture).+fixtureDirIO :: IO FilePath+fixtureDirIO = maybe (".." </> "plexus-macros" </> "fixtures" </> "conformance" </> "expected") id+  <$> lookupEnv "CONFORMANCE_FIXTURE_DIR"++-- | The fixture's IR: the activation at path @conformance@ — what+--   @synapse -i <backend> conformance@ builds when a backend serves it there+--   (less 'irMetadata', the toolchain and the clock).+fixtureIR :: PluginSchema -> Either IRBuildError IR+fixtureIR = buildIRFromSchema "conformance" ["conformance"]++-- | The bytes @ir.json@ holds: keys sorted, two-space indent, one trailing+--   newline.+renderIR :: IR -> LBS.ByteString+renderIR ir = encodePretty' defConfig { confIndent = Spaces 2, confCompare = compare } ir <> "\n"++main :: IO ()+main = do+  fixtureDir <- fixtureDirIO+  let schemaPath = fixtureDir </> "schema.json"+      irPath = fixtureDir </> "ir.json"+  schemaBytes <- LBS.readFile schemaPath+  schema <- either (fail . ((schemaPath <> ": ") <>)) pure (eitherDecode schemaBytes)+  ir <- either (fail . T.unpack . renderIRBuildError) pure (fixtureIR schema)+  accept <- lookupEnv "SYNAPSE_GOLDEN_ACCEPT"+  case accept of+    Just "1" -> do+      LBS.writeFile irPath (renderIR ir)+      putStrLn $ "wrote " <> irPath+    _ -> pure ()+  golden <- LBS.readFile irPath+  service <- serviceGoldens (accept == Just "1")+  hard <- hardGoldens (accept == Just "1")+  hspec $ do+    goldenSpec schemaPath irPath ir golden+    tableSpec ir+    service+    hard++-- | RETURNS-40's review: the reviewer's hard shapes (generics, recursion,+--   every serde enum form, a dotted $defs name, flattening, every number's+--   default, one title with two docs in two namespaces), served by+--   plexus-core's tests/ir_serving.rs and kept beside it+--   (@../plexus-core/tests/fixtures/ir-hard/@, or @IR_HARD_FIXTURE_DIR@):+--   @schemas.json@ in; @ir.json@, @ir.alpha.json@ and @ir.beta-inner.json@+--   out — the IR plexus-core's service writes is held to them.+hardGoldens :: Bool -> IO Spec+hardGoldens acceptIt = do+  dir <- maybe (".." </> "plexus-core" </> "tests" </> "fixtures" </> "ir-hard") id <$> lookupEnv "IR_HARD_FIXTURE_DIR"+  schemas <- readSchemas (dir </> "schemas.json")+  let builds =+        [ ("ir.json", buildIRFromSchemas "xsvc" [[]] schemas)+        , ("ir.alpha.json", buildIRFromSchemas "xsvc" [["alpha"]] schemas)+        , ("ir.beta-inner.json", buildIRFromSchemas "xsvc" [["beta"], ["alpha", "inner"]] schemas)+        ]+  irs <- traverse (\(file, built) -> (,) file <$> either (fail . T.unpack . renderIRBuildError) pure built) builds+  if acceptIt then mapM_ (\(file, ir) -> LBS.writeFile (dir </> file) (renderIR ir) >> putStrLn ("wrote " <> dir </> file)) irs else pure ()+  goldens <- traverse (\(file, ir) -> (\g -> (file, ir, g)) <$> LBS.readFile (dir </> file)) irs+  pure $ describe "golden: the reviewer's hard shapes (RETURNS-40)" $+    forM_ goldens $ \(file, ir, g) -> do+      it (file <> " byte for byte") $+        unless (renderIR ir == g) $ expectationFailure ("IR built from " <> dir </> "schemas.json differs from " <> file)+      it (file <> ": no RefNamed dangles") $+        danglingRefs ir `shouldBe` []++-- ============================================================================+-- RETURNS-40: a whole service's IR+-- ============================================================================++-- | The service fixture's directory: by sibling path, or wherever+--   @IR_SERVICE_FIXTURE_DIR@ says.+serviceDirIO :: IO FilePath+serviceDirIO = maybe (".." </> "plexus-macros" </> "fixtures" </> "ir-service" </> "expected") id+  <$> lookupEnv "IR_SERVICE_FIXTURE_DIR"++-- | Schemas by dotted path (@""@ the root), as the fixture keeps them.+readSchemas :: FilePath -> IO (Map.Map [Text] PluginSchema)+readSchemas path = do+  bytes <- LBS.readFile path+  byName <- either (fail . ((path <> ": ") <>)) pure (eitherDecode bytes) :: IO (Map.Map Text PluginSchema)+  pure (Map.mapKeys (\k -> if T.null k then [] else T.splitOn "." k) byName)++-- | Each golden of the service fixture: built, written when accepting, and+--   compared byte for byte.+serviceGoldens :: Bool -> IO Spec+serviceGoldens acceptIt = do+  dir <- serviceDirIO+  schemas <- readSchemas (dir </> "schemas.json")+  clash <- readSchemas (dir </> "clash.schemas.json")+  let builds =+        [ ("ir.json", "the whole service", buildIRFromSchemas "irsvc" [[]] schemas)+        , ("ir.alpha.json", "its alpha subtree", buildIRFromSchemas "irsvc" [["alpha"]] schemas)+        , ("clash.ir.json", "the clash service", buildIRFromSchemas "clashsvc" [[]] clash)+        ]+      -- RETURNS-36 review #9: the walk refuses the colliding activation and+      -- goes on; the one-plugin entry still fails+      clashOne = case Map.lookup ["clash"] clash of+        Just s -> either (\e -> LBS.fromStrict (TE.encodeUtf8 (renderIRBuildError e)) <> "\n") (const "built") (buildIRFromSchema "clashsvc" ["clash"] s)+        Nothing -> "no clash schema"+  irs <- traverse (\(file, what, built) -> do+           ir <- either (fail . T.unpack . renderIRBuildError) pure built+           pure (file, what, ir)) builds+  if acceptIt+    then do+      mapM_ (\(file, _, ir) -> LBS.writeFile (dir </> file) (renderIR ir) >> putStrLn ("wrote " <> dir </> file)) irs+      LBS.writeFile (dir </> "clash.one.error.txt") clashOne+    else pure ()+  goldens <- traverse (\(file, what, ir) -> (\g -> (file, what, ir, g)) <$> LBS.readFile (dir </> file)) irs+  clashGolden <- LBS.readFile (dir </> "clash.one.error.txt")+  pure $ describe "golden: the ir-service fixture's IR (RETURNS-40)" $ do+    forM_ goldens $ \(file, what, ir, g) -> do+      it (what <> " equals " <> file <> " byte for byte") $+        unless (renderIR ir == g) $ expectationFailure $+          "IR built from " <> dir </> "schemas.json" <> " differs from " <> file+            <> " (regenerate with SYNAPSE_GOLDEN_ACCEPT=1 and read the diff)"+      -- (Not "decodes back to the same IR": the root's call.params has+      -- "default": null, which the IR writes as pdDefault null — the same+      -- null an absent default writes — and reads back as no default.)+      it (what <> ": " <> file <> " decodes") $+        (eitherDecode g :: Either String IR) `shouldSatisfy` either (const False) (const True)+      it (what <> ": no RefNamed dangles") $+        danglingRefs ir `shouldBe` []+    it "the whole service carries the reader's refusals" $+      case [ ir | (f, _, ir, _) <- goldens, f == "ir.json" ] of+        (ir : _) -> null (irRefusals ir) `shouldBe` False+        [] -> expectationFailure "no ir.json"+    it "the clash service's walk refuses clash (keyword title, both sites named) and builds the rest" $+      case [ ir | (f, _, ir, _) <- goldens, f == "clash.ir.json" ] of+        (ir : _) -> do+          [ rfSite r | r <- irRefusals ir, rfKeyword r == "title" ] `shouldBe` ["clash"]+          [ m | m <- Map.keys (irMethods ir), "clash." `T.isPrefixOf` m ] `shouldBe` []+          Map.member "" (irPlugins ir) `shouldBe` True+        [] -> expectationFailure "no clash.ir.json"+    it "the clash plugin alone (buildIRFromSchema) still fails, as clash.one.error.txt says" $+      clashOne `shouldBe` clashGolden++-- ============================================================================+-- Golden+-- ============================================================================++goldenSpec :: FilePath -> FilePath -> IR -> LBS.ByteString -> Spec+goldenSpec schemaPath irPath ir golden = describe "golden: the conformance fixture's IR" $ do+  it "equals expected/ir.json byte for byte" $+    unless (renderIR ir == golden) $ expectationFailure $+      "IR built from " <> schemaPath <> " differs from " <> irPath+        <> " (regenerate with SYNAPSE_GOLDEN_ACCEPT=1 and read the diff)"++  it "expected/ir.json decodes back to the same IR" $+    eitherDecode golden `shouldBe` Right ir++  it "is IR 3.0" $+    irVersion ir `shouldBe` "3.0"++  it "has every method of the schema, keyed by full path" $+    Map.keys (irMethods ir) `shouldBe`+      [ "conformance.count", "conformance.load", "conformance.lookup", "conformance.ping"+      , "conformance.scan", "conformance.schema", "conformance.warm" ]++  it "carries the schema's own hash for the plugin" $ do+    -- the fixture's hash moves with every change to the fixture (RETURNS-10);+    -- read it from schema.json rather than pin one+    schema <- either fail pure . eitherDecode =<< LBS.readFile schemaPath+    fmap phiHash (Map.lookup "conformance" =<< irPluginHashes ir) `shouldBe` Just (psHash schema)++  it "no MethodDef carries mdStreaming" $+    forM_ (Map.elems (irMethods ir)) $ \md -> case Aeson.toJSON md of+      Object o -> KM.member "mdStreaming" o `shouldBe` False+      other -> expectationFailure ("not an object: " <> show other)++  it "no RefNamed dangles" $+    danglingRefs ir `shouldBe` []++  it "reports synapse's own version" $+    synapseVersion `shouldBe` "4.0.0"++-- ============================================================================+-- The Required behavior table, one row at a time+-- ============================================================================++tableSpec :: IR -> Spec+tableSpec fixture = describe "IR 3.0 Required behavior" $ do++  describe "cardinality: one / many → mdCardinality, never inferred" $ do+    it "reads the fixture's cardinalities as they are" $ do+      let card m = mdCardinality <$> Map.lookup ("conformance." <> m) (irMethods fixture)+      map card ["ping", "lookup", "schema"] `shouldBe` replicate 3 (Just CardinalityOne)+      map card ["count", "scan", "load", "warm"] `shouldBe` replicate 4 (Just CardinalityMany)++    it "a many-variant item type of a one method stays one (no variant counting)" $ do+      md <- single [aesonQQ|{+        "name": "status", "description": "", "hash": "h",+        "returns": { "title": "Status", "oneOf": [+          { "type": "object", "properties": { "type": { "type": "string", "const": "a" } }, "required": ["type"] },+          { "type": "object", "properties": { "type": { "type": "string", "const": "b" } }, "required": ["type"] },+          { "type": "object", "properties": { "type": { "type": "string", "const": "c" } }, "required": ["type"] } ] },+        "cardinality": "one" }|]+      mdCardinality md `shouldBe` CardinalityOne++    it "a struct item type of a many method stays many" $ do+      md <- single (withReturns "many" pongSchema)+      mdCardinality md `shouldBe` CardinalityMany++  describe "returns (any schema form) → a RefNamed with a TypeDef, or a primitive / array / optional ref" $ do+    it "a struct: the fixture's ping returns conformance.Pong, a struct" $ do+      fmap mdReturns (Map.lookup "conformance.ping" (irMethods fixture))+        `shouldBe` Just (named "conformance" "Pong")+      kindOf fixture "conformance.Pong" `shouldBe` Just (KindStruct+        [ FieldDef "message" (RefPrimitive "string" Nothing) Nothing True Nothing ])++    it "a tagged enum" $ do+      ir <- build [withReturns "one" [aesonQQ|{ "title": "Shape", "oneOf": [+          { "type": "object", "properties": { "r": { "type": "number" }, "type": { "type": "string", "const": "circle" } }, "required": ["type", "r"] },+          { "type": "object", "properties": { "type": { "type": "string", "const": "dot" } }, "required": ["type"] } ] }|]]+      returnsOf ir `shouldBe` named "t" "Shape"+      kindOf ir "t.Shape" `shouldBe` Just (taggedEnum "type"+        [ variant "circle" Nothing [FieldDef "r" (RefPrimitive "number" Nothing) Nothing True Nothing]+        , variant "dot" Nothing [] ])++    it "a string enum" $ do+      ir <- build [withReturns "one" [aesonQQ|{ "title": "Level", "type": "string", "enum": ["low", "high"] }|]]+      returnsOf ir `shouldBe` named "t" "Level"+      kindOf ir "t.Level" `shouldBe` Just (KindStringEnum ["low", "high"])++    it "a string enum written as consts" $ do+      ir <- build [withReturns "one" [aesonQQ|{ "title": "Level", "oneOf": [+          { "type": "string", "const": "low", "description": "Low." },+          { "type": "string", "const": "high", "description": "High." } ] }|]]+      kindOf ir "t.Level" `shouldBe` Just (KindStringEnum ["low", "high"])++    it "an alias: a titled $ref" $ do+      ir <- build [withReturns "one" [aesonQQ|{ "title": "Wrapper", "$ref": "#/$defs/Pong",+          "$defs": { "Pong": { "type": "object", "properties": { "message": { "type": "string" } }, "required": ["message"] } } }|]]+      returnsOf ir `shouldBe` named "t" "Wrapper"+      kindOf ir "t.Wrapper" `shouldBe` Just (KindAlias (named "t" "Pong"))+      fmap isStruct (kindOf ir "t.Pong") `shouldBe` Just True++    it "a primitive, whatever schemars titles it" $ do+      ir <- build [ withReturns "one" [aesonQQ|{ "title": "String", "type": "string" }|]+                  , withReturnsNamed "n" "one" [aesonQQ|{ "title": "uint32", "type": "integer", "format": "uint32", "minimum": 0 }|] ]+      returnsOf ir `shouldBe` RefPrimitive "string" Nothing+      fmap mdReturns (Map.lookup "t.n" (irMethods ir)) `shouldBe` Just (RefPrimitive "integer" (Just "uint32"))+      Map.keys (irTypes ir) `shouldBe` []++    it "an array of a named type" $ do+      ir <- build [withReturns "one" [aesonQQ|{ "title": "Array_of_Pong", "type": "array", "items": { "$ref": "#/$defs/Pong" },+          "$defs": { "Pong": { "type": "object", "properties": { "message": { "type": "string" } }, "required": ["message"] } } }|]]+      returnsOf ir `shouldBe` RefArray (named "t" "Pong")+      fmap isStruct (kindOf ir "t.Pong") `shouldBe` Just True++    it "an optional named type" $ do+      ir <- build [withReturns "one" [aesonQQ|{ "title": "Nullable_Pong", "anyOf": [ { "$ref": "#/$defs/Pong" }, { "type": "null" } ],+          "$defs": { "Pong": { "type": "object", "properties": { "message": { "type": "string" } }, "required": ["message"] } } }|]]+      returnsOf ir `shouldBe` RefOptional (named "t" "Pong")+      fmap isStruct (kindOf ir "t.Pong") `shouldBe` Just True++    it "any value ({}): the JSON type (IR 2.1's RefJson)" $ do+      ir <- build [withReturns "one" [aesonQQ|{}|]]+      returnsOf ir `shouldBe` RefJson+      Map.keys (irTypes ir) `shouldBe` []++    it "the fixture's schema method: SchemaResult, an untagged union of its members (IR 2.1's untagged form); its members are TypeDefs" $ do+      fmap mdReturns (Map.lookup "conformance.schema" (irMethods fixture))+        `shouldBe` Just (named "conformance" "SchemaResult")+      kindOf fixture "conformance.SchemaResult" `shouldBe` Just (untaggedEnum+        [ ("PluginSchema", named "conformance" "PluginSchema"), ("MethodSchema", named "conformance" "MethodSchema") ])+      fmap isStruct (kindOf fixture "conformance.PluginSchema") `shouldBe` Just True+      fmap isStruct (kindOf fixture "conformance.MethodSchema") `shouldBe` Just True++    it "an untagged union is IR 2.1's untagged enum, each variant its type, never a partial enum" $ do+      ir <- build [withReturns "one" [aesonQQ|{ "title": "Either", "anyOf": [ { "$ref": "#/$defs/A" }, { "$ref": "#/$defs/B" } ],+          "$defs": { "A": { "type": "object", "properties": { "a": { "type": "string" } }, "required": ["a"] },+                     "B": { "type": "object", "properties": { "b": { "type": "string" } }, "required": ["b"] } } }|]]+      returnsOf ir `shouldBe` named "t" "Either"+      kindOf ir "t.Either" `shouldBe` Just (untaggedEnum [("A", named "t" "A"), ("B", named "t" "B")])+      danglingRefs ir `shouldBe` []++  describe "error present → mdError = Just ref with TypeDef; absent → Nothing" $ do+    it "lookup's error is conformance.LookupError, a tagged enum" $ do+      fmap mdError (Map.lookup "conformance.lookup" (irMethods fixture))+        `shouldBe` Just (Just (named "conformance" "LookupError"))+      kindOf fixture "conformance.LookupError" `shouldBe` Just (taggedEnum "type"+        [ variant "not_found" Nothing [FieldDef "id" (RefPrimitive "string" Nothing) Nothing True Nothing] ])++    it "scan's and load's errors have TypeDefs" $ do+      fmap mdError (Map.lookup "conformance.scan" (irMethods fixture)) `shouldBe` Just (Just (named "conformance" "ScanError"))+      fmap mdError (Map.lookup "conformance.load" (irMethods fixture)) `shouldBe` Just (Just (named "conformance" "LoadError"))+      Map.member "conformance.ScanError" (irTypes fixture) `shouldBe` True+      Map.member "conformance.LoadError" (irTypes fixture) `shouldBe` True++    it "an infallible method has none" $+      forM_ ["ping", "count", "warm", "schema"] $ \m ->+        fmap mdError (Map.lookup ("conformance." <> m) (irMethods fixture)) `shouldBe` Just Nothing++  describe "progress present → mdProgress = Just ref with TypeDef; absent → Nothing" $ do+    it "load's progress is conformance.LoadStep, a tagged enum" $ do+      fmap mdProgress (Map.lookup "conformance.load" (irMethods fixture))+        `shouldBe` Just (Just (named "conformance" "LoadStep"))+      kindOf fixture "conformance.LoadStep" `shouldBe` Just (taggedEnum "type"+        [ variant "batch" Nothing+            [ FieldDef "done" (RefPrimitive "integer" (Just "uint32")) Nothing True Nothing+            , FieldDef "of" (RefPrimitive "integer" (Just "uint32")) Nothing True Nothing ] ])++    it "warm's progress is conformance.Note, the default" $ do+      fmap mdProgress (Map.lookup "conformance.warm" (irMethods fixture))+        `shouldBe` Just (Just (named "conformance" "Note"))+      fmap isStruct (kindOf fixture "conformance.Note") `shouldBe` Just True++    it "a method that reports no progress has none" $+      forM_ ["ping", "lookup", "count", "scan", "schema"] $ \m ->+        fmap mdProgress (Map.lookup ("conformance." <> m) (irMethods fixture)) `shouldBe` Just Nothing++  describe "tagged enum with any tag name → keDiscriminator = the property whose const every variant carries" $ do+    it "plexus-core's MethodRole (in the fixture's SchemaResult) is tagged by kind" $+      kindOf fixture "conformance.MethodRole" `shouldBe` Just (taggedEnum "kind"+        [ variant "rpc" Nothing []+        , variant "static_child" Nothing []+        , variant "dynamic_child" Nothing+            [ FieldDef "list_method" (RefOptional (RefPrimitive "string" Nothing)) Nothing False Nothing+            , FieldDef "search_method" (RefOptional (RefPrimitive "string" Nothing)) Nothing False Nothing ] ])++    it "a tag called kind" $ do+      ir <- build [withReturns "one" [aesonQQ|{ "title": "Event", "oneOf": [+          { "type": "object", "properties": { "kind": { "type": "string", "const": "created" }, "id": { "type": "string" } }, "required": ["kind", "id"] },+          { "type": "object", "properties": { "kind": { "type": "string", "const": "deleted" } }, "required": ["kind"] } ] }|]]+      kindOf ir "t.Event" `shouldBe` Just (taggedEnum "kind"+        [ variant "created" Nothing [FieldDef "id" (RefPrimitive "string" Nothing) Nothing True Nothing]+        , variant "deleted" Nothing [] ])++    it "an adjacently tagged enum: the tag, with its content a field" $ do+      ir <- build [withReturns "one" [aesonQQ|{ "title": "Msg", "oneOf": [+          { "type": "object", "properties": { "t": { "type": "string", "const": "text" }, "c": { "type": "string" } }, "required": ["t", "c"] },+          { "type": "object", "properties": { "t": { "type": "string", "const": "count" }, "c": { "type": "integer" } }, "required": ["t", "c"] } ] }|]]+      kindOf ir "t.Msg" `shouldBe` Just (taggedEnum "t"+        [ variant "text" Nothing [FieldDef "c" (RefPrimitive "string" Nothing) Nothing True Nothing]+        , variant "count" Nothing [FieldDef "c" (RefPrimitive "integer" Nothing) Nothing True Nothing] ])++    it "variants by $ref" $ do+      ir <- build [withReturns "one" [aesonQQ|{ "title": "Event", "oneOf": [ { "$ref": "#/$defs/Up" }, { "$ref": "#/$defs/Down" } ],+          "$defs": {+            "Up": { "type": "object", "properties": { "op": { "type": "string", "const": "up" }, "by": { "type": "integer" } }, "required": ["op", "by"] },+            "Down": { "type": "object", "properties": { "op": { "type": "string", "const": "down" } }, "required": ["op"] } } }|]]+      kindOf ir "t.Event" `shouldBe` Just (taggedEnum "op"+        [ variant "up" Nothing [FieldDef "by" (RefPrimitive "integer" Nothing) Nothing True Nothing]+        , variant "down" Nothing [] ])++  describe "newtype variant over a struct → variant fields = the struct's fields (from $defs)" $ do+    it "the 2020-12 form: $ref beside the tag" $ do+      ir <- build [withReturns "one" [aesonQQ|{ "title": "Event", "oneOf": [+          { "type": "object", "properties": { "type": { "type": "string", "const": "moved" } }, "$ref": "#/$defs/Move", "required": ["type"] },+          { "type": "object", "properties": { "type": { "type": "string", "const": "stopped" } }, "required": ["type"] } ],+          "$defs": { "Move": { "type": "object", "properties": { "x": { "type": "integer" }, "note": { "type": ["string", "null"] } }, "required": ["x"] } } }|]]+      kindOf ir "t.Event" `shouldBe` Just (taggedEnum "type"+        [ variant "moved" Nothing+            [ FieldDef "note" (RefOptional (RefPrimitive "string" Nothing)) Nothing False Nothing+            , FieldDef "x" (RefPrimitive "integer" Nothing) Nothing True Nothing ]+        , variant "stopped" Nothing [] ])++    it "the allOf form" $ do+      ir <- build [withReturns "one" [aesonQQ|{ "title": "Event", "oneOf": [+          { "type": "object", "properties": { "type": { "type": "string", "const": "moved" } }, "allOf": [ { "$ref": "#/$defs/Move" } ], "required": ["type"] } ],+          "$defs": { "Move": { "type": "object", "properties": { "x": { "type": "integer" } }, "required": ["x"] } } }|]]+      kindOf ir "t.Event" `shouldBe` Just (taggedEnum "type"+        [ variant "moved" Nothing [FieldDef "x" (RefPrimitive "integer" Nothing) Nothing True Nothing] ])++    it "a newtype variant over something with no fields is not a silent partial enum: the site is refused (IR 2.1)" $ do+      ir <- build [withReturns "one" [aesonQQ|{ "title": "Event", "oneOf": [+          { "type": "object", "properties": { "type": { "type": "string", "const": "named" } }, "$ref": "#/$defs/Name", "required": ["type"] } ],+          "$defs": { "Name": { "type": "string" } } }|]]+      map rfSite (irRefusals ir) `shouldBe` ["t.Event.named"]++  describe "field not in required → fdRequired = False" $ do+    it "in a struct: Note's percentage" $+      kindOf fixture "conformance.Note" `shouldBe` Just (KindStruct+        [ FieldDef "message" (RefPrimitive "string" Nothing) (Just "What is happening, for a human.") True Nothing+        , FieldDef "percentage" (RefOptional (RefPrimitive "number" (Just "float")))+            (Just "Completion, `0.0`–`100.0`, when the method can say.") False Nothing ])++    it "in a variant" $ do+      ir <- build [withReturns "one" [aesonQQ|{ "title": "Event", "oneOf": [+          { "type": "object", "properties": { "type": { "type": "string", "const": "seen" }, "at": { "type": "integer" }, "by": { "type": "string" } }, "required": ["type", "at"] } ] }|]]+      kindOf ir "t.Event" `shouldBe` Just (taggedEnum "type"+        [ variant "seen" Nothing+            [ FieldDef "at" (RefPrimitive "integer" Nothing) Nothing True Nothing+            , FieldDef "by" (RefPrimitive "string" Nothing) Nothing False Nothing ] ])++  describe "same title, same shape, two methods → one TypeDef" $ do+    it "two methods returning Pong, one of them through $defs" $ do+      ir <- build+        [ withReturnsNamed "a" "one" pongSchema+        , withReturnsNamed "b" "many" [aesonQQ|{ "title": "Array_of_Pong", "type": "array", "items": { "$ref": "#/$defs/Pong" },+            "$defs": { "Pong": { "description": "What ping answers.", "type": "object", "properties": { "message": { "type": "string" } }, "required": ["message"] } } }|] ]+      Map.keys (irTypes ir) `shouldBe` ["t.Pong"]+      fmap mdReturns (Map.lookup "t.a" (irMethods ir)) `shouldBe` Just (named "t" "Pong")+      fmap mdReturns (Map.lookup "t.b" (irMethods ir)) `shouldBe` Just (RefArray (named "t" "Pong"))++  describe "same title, different shapes, one namespace → build fails naming the two methods and the title" $ do+    it "fails" $ do+      let result = buildIn "t"+            [ withReturnsNamed "first" "one" pongSchema+            , withReturnsNamed "second" "one" [aesonQQ|{ "title": "Pong", "type": "object", "properties": { "reply": { "type": "integer" } }, "required": ["reply"] }|] ]+      result `shouldBe` Left TitleCollision+        { tcNamespace = "t"+        , tcTitle = "Pong"+        , tcFirst = TypeSource "first" "returns"+        , tcSecond = TypeSource "second" "returns" }+      case result of+        Left err -> do+          let msg = T.unpack (renderIRBuildError err)+          msg `shouldSatisfy` isInfixOf "\"Pong\""+          msg `shouldSatisfy` isInfixOf "method first (returns)"+          msg `shouldSatisfy` isInfixOf "method second (returns)"+        Right _ -> pure ()++    it "fails across a method's error and another's params $defs" $ do+      let result = buildIn "t"+            [ [aesonQQ|{ "name": "put", "description": "", "hash": "h", "cardinality": "one", "returns": {},+                "params": { "type": "object", "properties": { "why": { "$ref": "#/$defs/Why" } }, "required": ["why"],+                  "$defs": { "Why": { "type": "string", "enum": ["a", "b"] } } } }|]+            , [aesonQQ|{ "name": "get", "description": "", "hash": "h", "cardinality": "one", "returns": {},+                "error": { "title": "Why", "type": "string", "enum": ["c"] } }|] ]+      result `shouldBe` Left TitleCollision+        { tcNamespace = "t"+        , tcTitle = "Why"+        , tcFirst = TypeSource "put" "params"+        , tcSecond = TypeSource "get" "error" }++    it "the same title in two namespaces is two types" $ do+      a <- either (fail . T.unpack . renderIRBuildError) pure (buildIn "a" [withReturns "one" pongSchema])+      b <- either (fail . T.unpack . renderIRBuildError) pure+        (buildIn "b" [withReturns "one" [aesonQQ|{ "title": "Pong", "type": "object", "properties": { "reply": { "type": "integer" } }, "required": ["reply"] }|]])+      Map.keys (irTypes (mergeIR a b)) `shouldBe` ["a.Pong", "b.Pong"]++-- ============================================================================+-- Helpers+-- ============================================================================++pongSchema :: Value+pongSchema = [aesonQQ|{ "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "Pong", "description": "What ping answers.",+  "type": "object", "properties": { "message": { "type": "string" } }, "required": ["message"] }|]++-- | A method @m@ with this cardinality and @returns@.+withReturns :: Text -> Value -> Value+withReturns = withReturnsNamed "m"++withReturnsNamed :: Text -> Text -> Value -> Value+withReturnsNamed name card returns = object+  [ "name" .= name, "description" .= ("" :: Text), "hash" .= ("h" :: Text)+  , "returns" .= returns, "cardinality" .= card ]++-- | A plugin in namespace @ns@ holding these 0.7 method schemas, as IR.+buildIn :: Text -> [Value] -> Either IRBuildError IR+buildIn ns methods = case traverse Aeson.fromJSON methods of+  Aeson.Error e -> error ("bad MethodSchema: " <> e)+  Aeson.Success ms -> buildIRFromSchema "test" [ns] PluginSchema+    { psNamespace = ns, psVersion = "1", psDescription = "", psLongDescription = Nothing+    , psSelfHash = Nothing, psHash = "h", psMethods = ms, psChildren = Nothing+    , psDeprecation = Nothing, psRequest = Nothing, psRoles = Nothing, psView = Nothing }++build :: [Value] -> IO IR+build = either (fail . T.unpack . renderIRBuildError) pure . buildIn "t"++single :: Value -> IO MethodDef+single m = do+  ir <- build [m]+  case Map.elems (irMethods ir) of+    [md] -> pure md+    mds -> fail ("expected one method, got " <> show (length mds))++returnsOf :: IR -> TypeRef+returnsOf ir = maybe RefUnknown mdReturns (Map.lookup "t.m" (irMethods ir))++named :: Text -> Text -> TypeRef+named ns n = RefNamed (QualifiedName ns n)++-- | An internally tagged enum's kind (IR 2.1's 'KindEnum' carries its+--   representation; a tagged variant has no 'vdRef').+taggedEnum :: Text -> [VariantDef] -> TypeKind+taggedEnum tag vs = KindEnum tag vs Internal++-- | A tagged variant: its name, description and fields.+variant :: Text -> Maybe Text -> [FieldDef] -> VariantDef+variant name desc fields = VariantDef name desc fields Nothing++-- | IR 2.1's untagged union: each variant named by its type, its 'vdRef'.+untaggedEnum :: [(Text, TypeRef)] -> TypeKind+untaggedEnum vs = KindEnum "" [ VariantDef n Nothing [] (Just t) | (n, t) <- vs ] Untagged++-- | A type's kind, descriptions aside (of its fields and variants too).+kindOf :: IR -> Text -> Maybe TypeKind+kindOf ir name = undocumented . tdKind <$> Map.lookup name (irTypes ir)+  where+    undocumented = \case+      KindStruct fs -> KindStruct (map field fs)+      KindEnum d vs r -> KindEnum d [ v { vdDescription = Nothing, vdFields = map field (vdFields v) } | v <- vs ] r+      k -> k+    field f+      | name == "conformance.Note" = f  -- this row checks the field docs too+      | otherwise = f { fdDescription = Nothing }++isStruct :: TypeKind -> Bool+isStruct KindStruct{} = True+isStruct _ = False++-- | Every RefNamed in the IR's methods and types with no TypeDef.+danglingRefs :: IR -> [Text]+danglingRefs ir =+  [ n | r <- concatMap methodRefs (Map.elems (irMethods ir)) ++ concatMap typeRefs (Map.elems (irTypes ir))+      , n <- names r, not (Map.member n (irTypes ir)) ]+  where+    methodRefs md = mdReturns md : maybe [] pure (mdError md) ++ maybe [] pure (mdProgress md) ++ map pdType (mdParams md)+    typeRefs td = case tdKind td of+      KindStruct fs -> map fdType fs+      KindEnum _ vs _ -> map fdType (concatMap vdFields vs) ++ [ t | Just t <- map vdRef vs ]+      KindAlias t -> [t]+      _ -> []+    -- as irTypes keys a type: the root's are ".Name" (qualifiedNameFull+    -- would say "Name")+    names = \case+      RefNamed qn -> [qnNamespace qn <> "." <> qnLocalName qn]+      RefArray t -> names t+      RefOptional t -> names t+      _ -> []
test/IRCoverageSpec.hs view
@@ -13,13 +13,13 @@  import Control.Monad (forM_, unless, when) import qualified Codec.Compression.GZip as GZ-import Data.Aeson (Value(..), eitherDecode, encode, toJSON)+import Data.Aeson (Value(..), eitherDecode, encode, toJSON, (.=)) import qualified Data.Aeson as Aeson import Data.Aeson.Encode.Pretty (Config(..), defConfig, encodePretty') import qualified Data.Aeson.Key as K import qualified Data.Aeson.KeyMap as KM import qualified Data.ByteString.Lazy.Char8 as BL-import Data.IORef (writeIORef)+import Data.IORef (readIORef, writeIORef) import Data.List (isInfixOf, sort) import qualified Data.Map.Strict as Map import Data.Maybe (isJust)@@ -27,7 +27,7 @@ import Data.Text (Text) import qualified Data.Text as T import System.Directory (createDirectoryIfMissing, doesFileExist, findExecutable)-import System.Environment (getEnvironment, lookupEnv)+import System.Environment (getEnvironment, lookupEnv, setEnv, unsetEnv) import System.Exit (ExitCode(..)) import System.FilePath ((</>)) import System.IO.Temp (withSystemTempDirectory)@@ -35,10 +35,13 @@ import Test.Hspec  import Synapse.IR.Types-import Synapse.SchemaStore (Plan(..), Store(..), readPlan, writePlan)+import Synapse.SchemaStore (Plan(..), Store(..), readPlan, schemasRoot, storeFor, writePlan)+import Synapse.Endpoint (plainAt) import Plexus.Schema.Recursive (MethodSchema(..))  import IRFixture+import Plexus07 (methodAs07, withContract)+import Synapse.IR.Builder (IRBuildError(..), TypeSource(..), buildIRFromSchema) import MockService  main :: IO ()@@ -110,10 +113,18 @@       mdReturns <$> method ir "tickets.inputs" `shouldBe` Just (RefChild "tickets.inputs")       irRefusals ir `shouldBe` [] -    it "an rpc method with no returns is refused" $ do-      ir <- irOf "no-returns.json" ["x"]-      irRefusals ir `shouldBe` [Refusal "x.schema returns" "returns" Nothing "an rpc method with no returns schema"]-      mdReturns <$> method ir "x.schema" `shouldBe` Just RefUnknown+    -- RETURNS-36: plexus 0.7 requires returns, so such a method is no 0.7+    -- schema at all: refused by plexus-protocol before the builder sees it+    it "an rpc method with no returns is no 0.7 schema: it does not parse" $ do+      Right rows <- eitherDecode <$> BL.readFile (fixtures </> "no-returns.json") :: IO (Either String [Value])+      let methodsOf = \case+            Object r | Just (Object sch) <- KM.lookup "schema" r, Just (Array ms) <- KM.lookup "methods" sch -> foldr (:) [] ms+            _ -> []+          ms = map methodAs07 (concatMap methodsOf rows)+      ms `shouldNotBe` []+      forM_ ms $ \m -> case Aeson.fromJSON m :: Aeson.Result MethodSchema of+        Aeson.Error e -> e `shouldContain` "returns"+        Aeson.Success _ -> expectationFailure "a method with no returns parsed"      it "a struct root is a type, and no RefNamed dangles" $ do       ir <- irOf "hashes.json" ["tickets"]@@ -130,6 +141,16 @@         , InjectedParam "user" (obj [("from", "auth"), ("resolver", "self.access.verify")]) Nothing ]       irRefusals ir `shouldBe` [] +    it "mdInput: an Input<Bytes> item is a string of format binary, refused nowhere (BIDIR-18/19)" $ do+      ir <- irOf "input.json" ["tickets"]+      irRefusals ir `shouldBe` []+      mdInput <$> method ir "tickets.audio" `shouldBe` Just (Just (RefPrimitive "string" (Just "binary")))+      let Just audio = method ir "tickets.audio"+      encode (mdInput audio) `shouldBe` "{\"contents\":[\"string\",\"binary\"],\"tag\":\"RefPrimitive\"}"+      -- the bench server's ingest.items: its titled object a named type+      mdInput <$> method ir "tickets.items" `shouldBe` Just (Just (named "tickets" "Item"))+      kindOf ir "tickets.Item" `shouldSatisfy` isJust+     it "mdInput: the schema's input as a type; absent, no key" $ do       ir <- irOf "input.json" ["tickets"]       mdInput <$> method ir "tickets.chat" `shouldBe` Just (Just (named "tickets" "Token"))@@ -139,10 +160,10 @@       encode plain `shouldNotSatisfy` (contains "mdInjected")       kindOf ir "tickets.Token" `shouldSatisfy` isJust       -- plexus-protocol reads it, and writes it back only when present-      let Right ms = Aeson.eitherDecode "{\"name\":\"chat\",\"input\":{\"type\":\"string\"}}" :: Either String MethodSchema+      let Right ms = Aeson.eitherDecode "{\"name\":\"chat\",\"returns\":{},\"cardinality\":\"one\",\"input\":{\"type\":\"string\"}}" :: Either String MethodSchema       methodInput ms `shouldBe` Just (obj [("type", "string")])       encode ms `shouldSatisfy` (contains "\"input\"")-      let Right bare = Aeson.eitherDecode "{\"name\":\"plain\"}" :: Either String MethodSchema+      let Right bare = Aeson.eitherDecode "{\"name\":\"plain\",\"returns\":{},\"cardinality\":\"one\"}" :: Either String MethodSchema       encode bare `shouldNotSatisfy` (contains "\"input\"")    describe "the JSON type (a serde_json::Value)" $ do@@ -226,11 +247,63 @@             typesLeftOut = [ r | r <- Set.toList refused, not (Map.member r (irTypes ir)), not (T.any (`elem` (" (" :: String)) r) ]         [ s | s <- unknownSites ir, not (Set.member s refused), null typesLeftOut ] `shouldBe` [] +  -- RETURNS-36: synapse 4.0 reads the published 0.7.0 line too. The capture+  -- as menger served it — no cardinality, a streaming flag on each method —+  -- builds IR 3.0 whose every cardinality is that method's own flag.+  describe "menger, 2026-10-29, as served (0.7.0)" $+    it "every method's mdCardinality is its own streaming flag" $ do+      rows <- map (either error id . eitherDecode) . filter (not . BL.null) . BL.lines . GZ.decompress <$> BL.readFile menger20261029 :: IO [Value]+      let served = [ (p, sch)+                   | Object r <- rows, Just pv <- [KM.lookup "path" r], Aeson.Success p <- [Aeson.fromJSON pv]+                   , Just sch <- [KM.lookup "schema" r] ]+          flags = [ (T.intercalate "." p <> "." <> n, st)+                  | (p, Object sch) <- served, Just (Array ms) <- [KM.lookup "methods" sch]+                  , Object m <- foldr (:) [] ms, Just (String n) <- [KM.lookup "name" m]+                  , let st = KM.lookup "streaming" m == Just (Bool True) ]+      schemas <- mapM (\(p, v) -> case Aeson.fromJSON v of+                          Aeson.Success sch -> pure (p, sch)+                          Aeson.Error e -> fail (T.unpack (T.intercalate "." p) <> ": " <> e)) served+      env <- envWithSchemas "menger" schemas+      ir <- buildFixtureIR env []+      length flags `shouldSatisfy` (> 100)+      -- chats.inputs is refused whole (two types titled Message), so not here+      [ (path, st) | (path, st) <- flags, not ("chats.inputs." `T.isPrefixOf` path)+                   , fmap mdCardinality (Map.lookup path (irMethods ir)) /= Just (if st then CardinalityMany else CardinalityOne) ]+        `shouldBe` []+   describe "menger, 2026-10-29, whole" $ do-    it "is refused at exactly the walk's 21 sites; the root's carriers are the JSON type" $ do+    -- RETURNS-13: one title in one namespace is one type. The capture's+    -- chats.inputs names two (Message: the vocabulary's string in params,+    -- the chat's message in returns); 2.1 kept the first, 3.0 refuses: the+    -- one plugin's build fails, and the walk refuses that activation, naming+    -- both, and goes on (the review, #9).+    it "as captured, chats.inputs puts two types under Message: the build fails naming both" $ do+      rows <- map (either error id . eitherDecode) . filter (not . BL.null) . BL.lines . GZ.decompress <$> BL.readFile menger20261029 :: IO [Value]+      let inputs = [ sch | Object r <- rows, KM.lookup "path" r == Just (toJSON (["chats", "inputs"] :: [Text])), Just sch <- [KM.lookup "schema" r] ]+      [schema] <- pure [ ps | v <- inputs, Aeson.Success ps <- [Aeson.fromJSON (withContract v)] ]+      case buildIRFromSchema "menger" ["chats", "inputs"] schema of+        Left err -> do+          (tcNamespace err, tcTitle err) `shouldBe` ("chats.inputs", "Message")+          (tcFirst err, tcSecond err) `shouldBe` (TypeSource "list" "params", TypeSource "list" "returns")+        Right _ -> expectationFailure "two types under one title built"+    it "the walk refuses chats.inputs, naming both sites, leaves it out, and goes on" $ do       ir <- mengerIR-      map (\r -> (rfSite r, rfKeyword r)) (irRefusals ir) `shouldBe` expectedMengerRefusals-      sort (unknownSites ir) `shouldBe` sort (map fst expectedMengerRefusals)+      [ (rfSite r, rfKeyword r) | r <- irRefusals ir ] `shouldBe` [("chats.inputs", "title")]+      [ rfDetail r | r <- irRefusals ir ] `shouldSatisfy` all (\d -> all (`T.isInfixOf` d) ["\"Message\"", "method list (params)", "method list (returns)"])+      [ k | k <- Map.keys (irMethods ir), "chats.inputs." `T.isPrefixOf` k ] `shouldBe` []+      [ k | k <- Map.keys (irTypes ir), "chats.inputs." `T.isPrefixOf` k ] `shouldBe` []+      Map.member "chats.post" (irMethods ir) `shouldBe` True+      Map.member "tickets.get" (irMethods ir) `shouldBe` True++    -- RETURNS-36: 2.1 refused the walk at the 21 schema methods' returns,+    -- which 0.6 left unstated; a 0.7 schema states every method's returns+    -- (the captures are given `{}` for them, 'withContract'), so those+    -- return the JSON type (chats.inputs' aside: it is refused whole).+    it "the 20 schema methods 2.1 refused return the JSON type; the root's carriers are the JSON type" $ do+      ir <- mengerIR+      unknownSites ir `shouldBe` []+      let schemaMethods = [ T.dropEnd (T.length " returns") site | (site, _) <- expectedMengerRefusals, site /= "chats.inputs.schema returns" ]+      [ mdReturns <$> Map.lookup m (irMethods ir) | m <- schemaMethods ] `shouldBe` replicate 20 (Just RefJson)       danglingRefs ir `shouldBe` []       param ir ".call" "params" `shouldBe` Just RefJson       fieldType ir ".PlexusStreamItem" "data" "content" `shouldBe` Just RefJson@@ -250,12 +323,13 @@         `shouldBe` [RefArray (RefTuple [RefPrimitive "string" Nothing, RefPrimitive "string" Nothing])]       kindOf ir ".PluginHashes" `shouldSatisfy` isJust       -- no injected param is a param any more: 88 auth, 88 user-      length [ () | md <- Map.elems (irMethods ir), ip <- mdInjected md ] `shouldBe` 176+      -- (chats.inputs' 10 aside: it is refused whole)+      length [ () | md <- Map.elems (irMethods ir), ip <- mdInjected md ] `shouldBe` 166       [ pdName p | md <- Map.elems (irMethods ir), p <- mdParams md, pdName p `elem` ["auth", "user"] ] `shouldBe` [] -    it "equals its golden (menger-2026-10-29.ir-2.1.json)" $ do+    it "equals its golden (menger-2026-10-29.ir-3.0.json)" $ do       ir <- mengerIR-      let golden = fixtures </> "menger-2026-10-29.ir-2.1.json"+      let golden = fixtures </> "menger-2026-10-29.ir-3.0.json"           bytes = encodePretty' defConfig { confCompare = compare } ir { irMetadata = Nothing }       write <- lookupEnv "IR_GOLDEN_WRITE"       exists <- doesFileExist golden@@ -277,6 +351,7 @@       let typeMismatches =             [ "type " <> T.unpack key <> " changed:\n  old " <> show (norm v) <> "\n  new " <> show (map norm candidates)             | (k, v) <- KM.toList oldTypes, not (changedIn20 v)+            , not ("chats.inputs." `T.isPrefixOf` K.toText k)   -- refused whole in 3.0             , let key = K.toText k                   local = T.takeWhileEnd (/= '.') key                   candidates = case Map.lookup key newTypes of@@ -287,25 +362,43 @@                     Nothing -> moved n == moved v             , not (any same candidates) ]           moved = norm . dropKeys ["tdNamespace", "tdDescription"]-      -- methods: the same JSON but for the keys 2.1 adds and the injected params it moves+      -- methods: the same JSON but for the keys 2.1 adds and the injected+      -- params it moves, and 3.0's contract in place of 2.0's streaming+      -- guess (RETURNS-13: mdError, mdProgress, mdCardinality; the 2026-10-29+      -- schemas state no error or progress)           methodMismatches =             [ "method " <> T.unpack key <> ":\n  old " <> show old' <> "\n  new " <> show new'             | (k, v) <- KM.toList oldMethods             , let key = K.toText k-                  old' = norm (dropInjected v)-                  new' = maybe Null (norm . dropKeys ["mdInjected", "mdInput"] . toJSON) (Map.lookup key (irMethods ir))-            , not (changedIn20 (dropInjected v)), old' /= new' ]+                  old' = norm (dropKeys ["mdStreaming"] (dropInjected v))+                  new' = maybe Null (norm . dropKeys ["mdInjected", "mdInput", "mdError", "mdProgress", "mdCardinality"] . toJSON) (Map.lookup key (irMethods ir))+            , not (changedIn20 (dropInjected v)), old' /= new'+            -- chats.inputs is refused whole in 3.0 (two types titled Message)+            , not ("chats.inputs." `T.isPrefixOf` key) ]+          -- the name IRFixture gave chats.inputs' params' Message, back       mapM_ putStrLn (take 5 (typeMismatches ++ methodMismatches))       (length typeMismatches, length methodMismatches) `shouldBe` (0, 0)       -- and the guard is not vacuous: most of 2.0 was typed       length [ () | (_, v) <- KM.toList oldTypes, not (changedIn20 v) ] `shouldSatisfy` (> 150)       length [ () | (_, v) <- KM.toList oldMethods, not (changedIn20 (dropInjected v)) ] `shouldSatisfy` (> 100) -  describe "caches another synapse wrote" $+  describe "caches another synapse wrote" $ do+    -- RETURNS-36 review (#10): 4.0's schemas and plans (plexus 0.8's+    -- shape: cardinality, no streaming) are kept apart from 3.x's+    -- (~/.cache/synapse/schemas), so a rollback to synapse.prev never reads+    -- an entry 4.0 wrote, nor 4.0 one of 3.x's+    it "4.0 keeps its schema cache in a directory of its own layout, never 3.x's" $+      withSystemTempDirectory "r36-cache" $ \dir -> do+        setEnv "XDG_CACHE_HOME" dir+        root <- schemasRoot+        st <- storeFor (plainAt "127.0.0.1" 4455) "builder" "menger"+        unsetEnv "XDG_CACHE_HOME"+        root `shouldBe` dir </> "synapse" </> "schemas-4"+        storeDir st `shouldStartWith` (dir </> "synapse" </> "schemas-4" </> "")     it "a plan whose IR is 2.0 (3.16.0's) is read as no plan" $       withSystemTempDirectory "cc8-store" $ \dir -> do         let st = Store dir-            Right ms = Aeson.eitherDecode "{\"name\":\"get\"}" :: Either String MethodSchema+            Right ms = Aeson.eitherDecode "{\"name\":\"get\",\"returns\":{},\"cardinality\":\"one\"}" :: Either String MethodSchema             plan v = Plan ms ["tickets", "get"] Nothing (Just emptyIR { irVersion = v })         createDirectoryIfMissing True (dir </> "plans")         writePlan st ["tickets", "get"] (plan "2.0")@@ -317,29 +410,120 @@     exe <- runIO builtSynapse     case exe of       Nothing -> it "against a service with a bare object" $ pendingWith "the built synapse is not on PATH (cabal test puts it there)"-      Just bin -> it "against a service with a bare object: the IR on stdout, the site on stderr, exit 65" $ do-        rows <- loadSchemasValues (fixtures </> "untyped.json")+      Just bin -> do         let root = Object (KM.fromList               [ ("namespace", String "menger"), ("version", String "1.0.0"), ("description", String "root")               , ("hash", String "r-cc8"), ("methods", Array mempty)               , ("children", toJSON [ Object (KM.fromList [("namespace", String "tickets"), ("description", String "t"), ("hash", String "t-cc8")]) ]) ])-        writeIORef schemaOverride (Just (("", root) : rows))-        withSystemTempDirectory "cc8-cli" $ \dir -> withMockService todaysService $ \port -> do-          forM_ ["home", "cache", "run"] $ \d -> createDirectoryIfMissing True (dir </> d)-          env0 <- getEnvironment-          let env = [ (k, v) | (k, v) <- env0, k `notElem` ["HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR", "SYNAPSE_AS", "PLEXUS_SESSION"] ]-                 ++ [ ("HOME", dir </> "home"), ("XDG_CACHE_HOME", dir </> "cache"), ("XDG_RUNTIME_DIR", dir </> "run")-                    , ("PLEXUS_KEYCHAIN", "off"), ("SYNAPSE_HOLD_FOR", "3s") ]-          (code, out, err) <- readCreateProcessWithExitCode-            (proc bin ["--url", "ws://127.0.0.1:" <> show port <> "/ws", "-i", "menger"]) { env = Just env } ""-          writeIORef schemaOverride Nothing+            emitIR rows = do+              writeIORef schemaOverride (Just (("", root) : rows))+              withSystemTempDirectory "cc8-cli" $ \dir -> withMockService todaysService $ \port -> do+                forM_ ["home", "cache", "run"] $ \d -> createDirectoryIfMissing True (dir </> d)+                env0 <- getEnvironment+                let env = [ (k, v) | (k, v) <- env0, k `notElem` ["HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR", "SYNAPSE_AS", "PLEXUS_SESSION"] ]+                       ++ [ ("HOME", dir </> "home"), ("XDG_CACHE_HOME", dir </> "cache"), ("XDG_RUNTIME_DIR", dir </> "run")+                          , ("PLEXUS_KEYCHAIN", "off"), ("SYNAPSE_HOLD_FOR", "3s") ]+                r <- readCreateProcessWithExitCode+                  (proc bin ["--url", "ws://127.0.0.1:" <> show port <> "/ws", "-i", "menger"]) { env = Just env } ""+                writeIORef schemaOverride Nothing+                pure r+        it "against a service with a bare object: the IR on stdout, the site on stderr, exit 65" $ do+          rows <- loadSchemasValues (fixtures </> "untyped.json")+          (code, out, err) <- emitIR rows           code `shouldBe` ExitFailure 65           err `shouldSatisfy` ("tickets.Thing.bare" `isInfixOf`)           err `shouldSatisfy` ("neither properties nor additionalProperties" `isInfixOf`)+          -- nothing was left out: main's header+          err `shouldSatisfy` ("nothing was dropped" `isInfixOf`)           case eitherDecode (BL.pack out) :: Either String IR of             Left e -> expectationFailure ("stdout is not an IR: " <> e <> "\n" <> take 400 out)             Right ir -> map rfSite (irRefusals ir) `shouldBe` ["tickets.Thing.bare", "tickets.Thing.list[]", "tickets.Thing.mixed"]+        -- RETURNS-36 review: a title collision leaves its activation out, so+        -- the header says so, by namespace, and never "nothing was dropped"+        it "against a service with two types under one title: the header says which activation was left out, exit 65" $ do+          let pong props = object07 ["title" .= ("Pong" :: Text), "type" .= ("object" :: Text), "properties" .= props]+              method n props = object07 [ "name" .= (n :: Text), "description" .= n, "hash" .= (n <> "0"), "streaming" .= False+                                        , "returns" .= pong props ]+              tickets = object07+                [ "namespace" .= ("tickets" :: Text), "version" .= ("1" :: Text), "description" .= ("t" :: Text), "hash" .= ("t-cc8" :: Text)+                , "methods" .= [ method "a" (object07 ["message" .= object07 ["type" .= ("string" :: Text)]])+                               , method "b" (object07 ["reply" .= object07 ["type" .= ("integer" :: Text)]]) ] ]+          (code, out, err) <- emitIR [("tickets", tickets)]+          code `shouldBe` ExitFailure 65+          err `shouldNotSatisfy` ("nothing was dropped" `isInfixOf`)+          err `shouldSatisfy` ("left out: tickets" `isInfixOf`)+          case eitherDecode (BL.pack out) :: Either String IR of+            Left e -> expectationFailure ("stdout is not an IR: " <> e <> "\n" <> take 400 out)+            Right ir -> map (\r -> (rfSite r, rfKeyword r)) (irRefusals ir) `shouldBe` [("tickets", "title")] +  -- RETURNS-40: synapse asks the service for its IR first — one call — and+  -- walks the raw schemas only when the service has no `ir`.+  describe "synapse -i, a service that writes its IR" $ do+    exe <- runIO builtSynapse+    case exe of+      Nothing -> it "asks ir" $ pendingWith "the built synapse is not on PATH (cabal test puts it there)"+      Just bin -> do+        let runI args = withSystemTempDirectory "r40-cli" $ \dir -> withMockService todaysService $ \port -> do+              forM_ ["home", "cache", "run"] $ \d -> createDirectoryIfMissing True (dir </> d)+              env0 <- getEnvironment+              let env = [ (k, v) | (k, v) <- env0, k `notElem` ["HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR", "SYNAPSE_AS", "PLEXUS_SESSION"] ]+                     ++ [ ("HOME", dir </> "home"), ("XDG_CACHE_HOME", dir </> "cache"), ("XDG_RUNTIME_DIR", dir </> "run")+                        , ("PLEXUS_KEYCHAIN", "off"), ("SYNAPSE_HOLD_FOR", "3s") ]+              writeIORef callsMade []+              writeIORef irAsked []+              r <- readCreateProcessWithExitCode+                (proc bin (["--url", "ws://127.0.0.1:" <> show port <> "/ws", "-i", "menger"] <> args)) { env = Just env } ""+              calls <- readIORef callsMade+              asked <- readIORef irAsked+              pure (r, calls, asked)+            served = emptyIR+              { irBackend = "theirs", irHash = Just "h-r40"+              , irPlugins = Map.fromList [("tickets", ["get"])]+              , irRefusals = [] }+            sameIR a b = (irTypes a, irMethods a, irPlugins a, irHash a, irRefusals a) `shouldBe` (irTypes b, irMethods b, irPlugins b, irHash b, irRefusals b)+        it "the root lists ir: its schema, then one call to ir, and its IR — named for the backend, with synapse's metadata" $ do+          writeIORef irAnswer (Just (Aeson.object ["ok" Aeson..= served]))+          ((code, out, err), calls, asked) <- runI []+          writeIORef irAnswer Nothing+          (code, err) `shouldBe` (ExitSuccess, "")+          calls `shouldBe` ["menger.schema", "menger.ir"]+          asked `shouldBe` [Aeson.object []]+          case eitherDecode (BL.pack out) :: Either String IR of+            Left e -> expectationFailure ("stdout is not an IR: " <> e)+            Right ir -> do+              ir `sameIR` served+              irBackend ir `shouldBe` "menger"+              irMetadata ir `shouldSatisfy` isJust+        it "a path is the namespace asked for" $ do+          writeIORef irAnswer (Just (Aeson.object ["ok" Aeson..= served]))+          (_, calls, asked) <- runI ["tickets"]+          writeIORef irAnswer Nothing+          calls `shouldBe` ["menger.schema", "menger.ir"]+          asked `shouldBe` [Aeson.object ["namespaces" Aeson..= (["tickets"] :: [Text])]]+        it "a refusal is the service's, said in its words, and nothing walked" $ do+          writeIORef irAnswer (Just (Aeson.object ["err" Aeson..= Aeson.object+            [ "type" Aeson..= ("not_found" :: Text), "namespace" Aeson..= ("delta" :: Text)+            , "message" Aeson..= ("no namespace delta in this service" :: Text) ]]))+          ((code, _, err), calls, _) <- runI []+          writeIORef irAnswer Nothing+          code `shouldNotBe` ExitSuccess+          err `shouldSatisfy` ("no namespace delta in this service" `isInfixOf`)+          calls `shouldBe` ["menger.schema", "menger.ir"]+        it "an IR of another version is not read: the walk builds it" $ do+          writeIORef irAnswer (Just (Aeson.object ["ok" Aeson..= served { irVersion = "2.1" }]))+          ((code, out, _), calls, _) <- runI []+          writeIORef irAnswer Nothing+          code `shouldBe` ExitSuccess+          take 2 calls `shouldBe` ["menger.schema", "menger.ir"]+          fmap irVersion (eitherDecode (BL.pack out) :: Either String IR) `shouldBe` Right currentIrVersion+        it "a root that lists no ir (0.7.0): no call to ir, the walk as before" $ do+          ((code, out, _), calls, _) <- runI []+          code `shouldBe` ExitSuccess+          calls `shouldSatisfy` notElem "menger.ir"+          take 1 calls `shouldBe` ["menger.schema"]+          length (filter (".schema" `T.isSuffixOf`) calls) `shouldSatisfy` (> 1)+          fmap irVersion (eitherDecode (BL.pack out) :: Either String IR) `shouldBe` Right currentIrVersion+ -- ---------------------------------------------------------------------------  fixtures :: FilePath@@ -355,7 +539,7 @@ smallFixtures =   [ ("transition-field.json", ["tickets"]), ("answer-source.json", ["tickets"]), ("reply.json", ["tickets"])   , ("hash-event.json", ["tickets"]), ("login-flow.json", ["auth"]), ("schema-result.json", ["tickets"])-  , ("unreadable-oneof.json", ["tickets"]), ("static-child.json", ["tickets"]), ("no-returns.json", ["x"])+  , ("unreadable-oneof.json", ["tickets"]), ("static-child.json", ["tickets"])   , ("hashes.json", ["tickets"]), ("injected.json", ["tickets"])   , ("untyped.json", ["tickets"]), ("json.json", ["tickets"]), ("theme-map.json", ["themes"])   , ("component-preview.json", ["tickets"]), ("bounds.json", ["tickets"]), ("allof.json", ["tickets"])@@ -374,6 +558,9 @@  named :: Text -> Text -> TypeRef named ns n = RefNamed (QualifiedName ns n)++object07 :: [(K.Key, Value)] -> Value+object07 = Object . KM.fromList  obj :: [(Text, Text)] -> Value obj kvs = Object (KM.fromList [ (K.fromText k, String v) | (k, v) <- kvs ])
test/IRFixture.hs view
@@ -24,6 +24,7 @@ import Data.Text (Text) import qualified Katip +import Plexus07 (withContract) import Synapse.IR.Builder (buildIR) import Synapse.IR.Types (IR) import qualified Synapse.Log as Log@@ -35,8 +36,13 @@  data Row = Row Path PluginSchema +-- | RETURNS-36: a row's schema as plexus 0.7 states it ('withContract': its+--   methods' cardinality, and returns where a fixture states none; the+--   captures are 0.6's). The capture's types are read as served. instance FromJSON Row where-  parseJSON = withObject "Row" $ \o -> Row <$> o .: "path" <*> o .: "schema"+  parseJSON = withObject "Row" $ \o -> do+    v <- o .: "schema"+    Row <$> o .: "path" <*> parseJSON (withContract v)  -- | Every schema of a fixture, by path: a @.jsonl.gz@ one row a line --   (gunzipped), a @.json@ an array of rows.
test/IRSpec.hs view
@@ -89,7 +89,7 @@ irSpec ir = do   describe "Schema fetching" $ do     it "builds IR from root" $-      irVersion ir `shouldBe` "2.0"+      irVersion ir `shouldBe` "3.0"      it "IR contains methods" $       Map.size (irMethods ir) `shouldSatisfy` (> 0)
+ test/InputSpec.hs view
@@ -0,0 +1,317 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}++-- | BIDIR-18: synapse calls a method with an input half (plexus-wire+-- spec/10). The built synapse, end to end against the mock service's+-- @stream@ activation (a home, a cache and a runtime directory of the+-- test's own), the frames counted where they land:+--+-- * lines as string items, NDJSON as object items, a bad line named by its+--   number and never carried;+-- * stdin not read past the credit (a service granting 4 and never acking),+--   and synapse's memory flat while the writer runs ahead;+-- * bytes as binary frames — header, payload, @--chunk@, the call's seq;+-- * a disconnect said on stderr with the counts, exit 69, the call never+--   made again;+-- * the typed closes' exits: 70, 65, 77;+-- * a parameter named @input@ keeps its flag (the stream is+--   @--input-stream@); no stream given is refused before anything is sent;+--   @-n@ prints the open frame and the first input frame.+module Main (main) where++import Control.Concurrent (forkIO, threadDelay)+import Control.Exception (SomeException, finally, try)+import Control.Monad (forM_)+import Data.Aeson+import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as BS8+import qualified Data.ByteString.Lazy.Char8 as LBS8+import Data.IORef+import Data.List (isInfixOf, isPrefixOf)+import Data.Text (Text)+import qualified Data.Text as T+import System.Directory (createDirectoryIfMissing, findExecutable, listDirectory)+import System.Environment (getEnvironment)+import System.Exit (ExitCode(..))+import System.FilePath ((</>))+import System.IO (hClose, hFlush, hSetBinaryMode, hSetBuffering, BufferMode(..))+import System.Timeout (timeout)+import System.IO.Temp (withSystemTempDirectory)+import System.Posix.Signals (signalProcess, sigKILL, sigTERM)+import System.Process+import Test.Hspec++import MockService++main :: IO ()+main = hspec $ do+  synapse <- runIO (findExecutable "synapse")+  case synapse of+    Nothing -> it "(the synapse executable is not on PATH)" $ pendingWith "cabal test puts it there"+    Just exe -> inputSpec exe++inputSpec :: FilePath -> Spec+inputSpec exe = describe "a call with an input half (BIDIR-18)" $ do+  forM_ [(["--no-hold"], "on its own socket"), ([], "through the holder")] $ \(how, label) -> describe label $ do+    it "sends lines as string items, one a line, an empty one too" $ withRun $ \run dir -> do+      writeFile (dir </> "log.txt") "first\n\nthird\n"+      (code, out, err) <- run how ["stream", "lines", "--input", "@" <> dir </> "log.txt"] ""+      code `shouldBe` ExitSuccess+      out `shouldContain` "items: 3"+      err `shouldContain` "input: sent 3 items in 1 frame"+      itemsReceived `shouldReturn` [String "first", String "", String "third"]+      ends `shouldReturn` [1]+      readIORef inputViolations `shouldReturn` []+      -- through the holder: a holder process under this test's runtime directory+      holders <- holdersUnder dir+      (if null how then length holders `shouldBe` 1 else holders `shouldBe` [])++    it "sends NDJSON as object items, in order, under credit (10,000 from stdin)" $ withRun $ \run _ -> do+      let items = [ object ["at" .= i, "pad" .= ("p" <> show i)] | i <- [1 .. 10000 :: Int] ]+      (code, out, err) <- run how ["stream", "ingest", "--input", "-"] (unlines (map (LBS8.unpack . encode) items))+      code `shouldBe` ExitSuccess+      out `shouldContain` "items: 10000"+      err `shouldContain` "input: sent 10,000 items"+      err `shouldContain` "0 unacked"+      itemsReceived `shouldReturn` items+      readIORef inputViolations `shouldReturn` []++    it "sends bytes as binary frames: the header, --chunk bytes each, seq counting every frame" $ withRun $ \run dir -> do+      let bytes = BS.pack [0 .. 22]+      BS.writeFile (dir </> "audio.raw") bytes+      (code, _, err) <- run how ["stream", "audio", "--input", "@" <> dir </> "audio.raw", "--chunk", "5"] ""+      (code, err) `shouldSatisfy` ((== ExitSuccess) . fst)+      rs <- readIORef inputReceived+      let bins = [ (sub, sq, p) | ReceivedBinary sub sq p <- rs ]+      map (\(_, sq, _) -> sq) bins `shouldBe` [0 .. 4]+      map (\(_, _, p) -> BS.length p) bins `shouldBe` [5, 5, 5, 5, 3]+      BS.concat [ p | (_, _, p) <- bins ] `shouldBe` bytes+      length (filter (\(sub, _, _) -> sub == firstSub bins) bins) `shouldBe` 5+      [ sq | ReceivedEnd sq <- rs ] `shouldBe` [5]+      readIORef inputViolations `shouldReturn` []++  it "names a line that is not JSON by its number; no frame carries it, and the call ends (65)" $ withRun $ \run dir -> do+    writeFile (dir </> "bad.jsonl") $ unlines+      [ "{\"at\":1,\"pad\":\"a\"}", "{\"at\":2,\"pad\":\"b\"}", "{\"at\":3,", "{\"at\":4,\"pad\":\"d\"}" ]+    (code, _, err) <- run ["--no-hold"] ["stream", "ingest", "--input", "@" <> dir </> "bad.jsonl"] ""+    code `shouldBe` ExitFailure 65+    err `shouldContain` "input line 3 is not an item"+    got <- itemsReceived+    got `shouldNotSatisfy` any (\v -> v == object ["at" .= (4 :: Int), "pad" .= ("d" :: Text)])+    length got `shouldSatisfy` (<= 2)+    ends `shouldReturn` []++  -- review of BIDIR-18: only the end of the source ends the input+  it "ends the call on a read that fails (exit 74), sending no end" $ withRun $ \run _ -> do+    (code, _, err) <- run ["--no-hold"] ["stream", "lines", "--input", "@/proc/self/mem"] ""+    code `shouldBe` ExitFailure 74+    err `shouldContain` "could not be read"+    ends `shouldReturn` []++  it "sends end when the source is over though no credit is left" $+    withRunPlan defaultInputPlan { planWindow = 2, planStingy = True } $ \run dir _ _ -> do+      writeFile (dir </> "two.txt") "a\nb\n"+      r <- timeout 15000000 (run ["--no-hold"] ["stream", "lines", "--input", "@" <> dir </> "two.txt"] "")+      fmap (\(c, _, _) -> c) r `shouldBe` Just ExitSuccess+      itemsReceived `shouldReturn` [String "a", String "b"]+      length <$> ends `shouldReturn` 1++  forM_ [("lines", "a\nb", "a line whole while the next is still arriving"), ("audio", "0123456789", "bytes as they come, short of --chunk")] $ \(m, sent, label) ->+    it ("sends " <> label <> " (a live source)") $ withRunPlan defaultInputPlan $ \_ _ env port -> do+      (Just hin, Just hout, Just herr, ph) <- createProcess+        (proc exe ["--no-hold", "--url", "ws://127.0.0.1:" <> show port <> "/ws", "menger", "stream", m, "--input", "-", "--chunk", "640"])+        { env = Just env, std_in = CreatePipe, std_err = CreatePipe, std_out = CreatePipe }+      hSetBinaryMode hin True+      BS.hPut hin (BS8.pack sent) >> hFlush hin+      got <- waitUntil 10 $ do+        rs <- readIORef inputReceived+        pure $ if m == "lines" then [ is | ReceivedJson _ is <- rs ] == [[String "a"]]+                               else [ p | ReceivedBinary _ _ p <- rs ] == [BS8.pack sent]+      rs <- readIORef inputReceived+      hClose hin+      _ <- waitForProcess ph+      mapM_ (\h -> hClose h `catchAny` \_ -> pure ()) [hout, herr]+      (got, rs) `shouldSatisfy` fst++  it "keeps a value that reads --progress for the parameter it follows" $ withRun $ \run dir -> do+    writeFile (dir </> "log.txt") "one\n"+    (code, out, _) <- run ["--no-hold"] ["stream", "tagged", "--input", "--progress", "--input-stream", "@" <> dir </> "log.txt"] ""+    code `shouldBe` ExitSuccess+    out `shouldContain` "tag: --progress"++  it "reads no more of stdin than the credit allows, its memory flat (a service granting 4, never acking)" $+    withRunPlan defaultInputPlan { planWindow = 4, planStingy = True } $ \_ _ env port -> do+      written <- newIORef (0 :: Int)+      (Just hin, Just hout, Just herr, ph) <- createProcess (proc exe (["--no-hold", "--url", "ws://127.0.0.1:" <> show port <> "/ws", "menger", "stream", "lines", "--input", "-"]))+        { env = Just env, std_in = CreatePipe, std_err = CreatePipe, std_out = CreatePipe }+      hSetBinaryMode hin True+      hSetBuffering hin NoBuffering+      let line = BS8.pack (replicate 1023 'x' <> "\n")+      _ <- forkIO $ (let go = BS.hPut hin line >> modifyIORef' written (+ 1) >> go in go)+                      `catchAny` \_ -> pure ()+      Just pid <- getPid ph+      threadDelay 1500000+      alive <- getProcessExitCode ph+      case alive of+        Just c -> do+          e <- BS.hGetContents herr+          expectationFailure ("synapse ended (" <> show c <> "): " <> BS8.unpack e)+        Nothing -> pure ()+      rss1 <- rssKb (show pid)+      threadDelay 3000000+      rss2 <- rssKb (show pid)+      n <- readIORef written+      got <- itemsReceived+      signalProcess sigTERM pid `catchAny` \_ -> pure ()+      _ <- waitForProcess ph+      mapM_ (\h -> hClose h `catchAny` \_ -> pure ()) [herr, hout]+      putStrLn ("      " <> show n <> " lines of 1 KiB written in 4.5 s; synapse RSS " <> show rss1 <> " kB at 1.5 s, " <> show rss2 <> " kB at 4.5 s")+      length got `shouldBe` 4+      -- 4 items, the read buffer synapse holds (32 KiB), and the pipe's+      -- 64 KiB: nothing like the writer's endless stream+      n `shouldSatisfy` (< 4 + 32 + 64 + 16)+      abs (rss2 - rss1) `shouldSatisfy` (<= 1024)+      readIORef inputViolations `shouldReturn` []++  it "says a disconnect on stderr with what was sent and acked, exits 69, and never makes the call again" $+    withRunPlan defaultInputPlan { planDropAfter = Just 3 } $ \run dir _ _ -> do+      writeFile (dir </> "many.jsonl") $ unlines [ "{\"at\":" <> show i <> ",\"pad\":\"x\"}" | i <- [1 .. 5000 :: Int] ]+      writeIORef callsMade []+      (code, _, err) <- run ["--no-hold"] ["stream", "ingest", "--input", "@" <> dir </> "many.jsonl"] ""+      code `shouldBe` ExitFailure 69+      err `shouldContain` "lost during the call"+      err `shouldContain` "not sent again"+      err `shouldContain` "input: sent "+      err `shouldContain` "unacked"+      calls <- readIORef callsMade+      length (filter (== "stream.ingest") calls) `shouldBe` 1++  forM_ [ ("past_credit", 70, "a bug in synapse"), ("invalid_input", 65, "refused the input (invalid_input) at frame 0")+        , ("too_much_input", 65, "too_much_input"), ("out_of_sequence", 65, "out_of_sequence") ] $ \(kind, exit, says) ->+    it ("exits " <> show exit <> " when the service closes the input with " <> T.unpack kind) $+      withRunPlan defaultInputPlan { planRefuse = Just (1, kind) } $ \run dir _ _ -> do+        writeFile (dir </> "two.txt") "a\nb\n"+        (code, _, err) <- run ["--no-hold"] ["stream", "lines", "--input", "@" <> dir </> "two.txt"] ""+        code `shouldBe` ExitFailure exit+        err `shouldContain` says+        err `shouldContain` "input: sent"++  it "exits 77 when the service closes the input for a revoked sign-in" $+    withRunPlan defaultInputPlan { planClose = Just (1, "revoked") } $ \run dir _ _ -> do+      writeFile (dir </> "two.txt") "a\nb\n"+      (code, _, err) <- run ["--no-hold"] ["stream", "lines", "--input", "@" <> dir </> "two.txt"] ""+      code `shouldBe` ExitFailure 77+      err `shouldContain` "revoked"++  it "keeps --input for a parameter named input; the stream is --input-stream" $ withRun $ \run dir -> do+    writeFile (dir </> "log.txt") "one\ntwo\n"+    (code, out, _) <- run ["--no-hold"] ["stream", "tagged", "--input", "t1", "--input-stream", "@" <> dir </> "log.txt"] ""+    code `shouldBe` ExitSuccess+    out `shouldContain` "tag: t1"+    itemsReceived `shouldReturn` [String "one", String "two"]++  it "refuses a call with no stream named, before anything is sent, naming the flag" $ withRun $ \run _ -> do+    writeIORef callsMade []+    (code, _, err) <- run ["--no-hold"] ["stream", "lines"] ""+    code `shouldNotBe` ExitSuccess+    err `shouldContain` "--input -"+    calls <- readIORef callsMade+    filter (\c -> "stream." `T.isPrefixOf` c && not (".schema" `T.isSuffixOf` c)) calls `shouldBe` []++  it "-n prints the open frame and the first input frame, and sends nothing" $ withRun $ \run dir -> do+    writeFile (dir </> "log.txt") "hello\nworld\n"+    writeIORef callsMade []+    (code, out, _) <- run ["--no-hold", "-n"] ["stream", "lines", "--input", "@" <> dir </> "log.txt"] ""+    code `shouldBe` ExitSuccess+    case lines out of+      [open, first] -> do+        open `shouldContain` "\"method\":\"menger.call\""+        first `shouldContain` "\"method\":\"menger.input\""+        first `shouldContain` "\"items\":[\"hello\"]"+      other -> expectationFailure ("two lines, not " <> show other)+    BS.writeFile (dir </> "a.raw") (BS.replicate 700 7)+    (_, out', _) <- run ["--no-hold", "-n"] ["stream", "audio", "--input", "@" <> dir </> "a.raw", "--chunk", "640"] ""+    out' `shouldContain` "binary: 01 0000000000000000 0000000000000000 + 640 bytes"+    calls <- readIORef callsMade+    filter (\c -> "stream." `T.isPrefixOf` c && not (".schema" `T.isSuffixOf` c)) calls `shouldBe` []++  it "--help says the input: its item type, binary or not, --input and --chunk" $ withRun $ \run _ -> do+    (_, out, _) <- run ["--no-hold"] ["stream", "audio", "--help"] ""+    out `shouldContain` "--input <-|@FILE>"+    out `shouldContain` "bytes items"+    out `shouldContain` "binary frame"+    out `shouldContain` "[--chunk <bytes>]"+    (_, out2, _) <- run ["--no-hold"] ["stream", "ingest", "--help"] ""+    out2 `shouldContain` "Item items"+    out2 `shouldContain` "NDJSON"+    (_, found, _) <- run ["--no-hold"] ["_find", "raw", "bytes"] ""+    found `shouldContain` "takes a stream in: --input -|@FILE"+  where+    withRun act = withRunPlan defaultInputPlan (\run dir _ _ -> act run dir)+    withRunPlan plan act = withSystemTempDirectory "b18" $ \dir -> withMockService todaysService $ \port -> do+      forM_ ["home", "cache", "run"] $ \d -> createDirectoryIfMissing True (dir </> d)+      env0 <- getEnvironment+      let env = [ (k, v) | (k, v) <- env0, k `notElem` ["HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR", "SYNAPSE_AS", "PLEXUS_SESSION", "SYNAPSE_NO_HOLD"] ]+              ++ [ ("HOME", dir </> "home"), ("XDG_CACHE_HOME", dir </> "cache"), ("XDG_RUNTIME_DIR", dir </> "run")+                 , ("PLEXUS_KEYCHAIN", "off"), ("SYNAPSE_HOLD_FOR", "5s"), ("SYNAPSE_RECONNECT_FOR", "3s") ]+          url = ["--url", "ws://127.0.0.1:" <> show port <> "/ws"]+          run' globals args input = readCreateProcessWithExitCode+            (proc exe (globals ++ url ++ case args of+                         ("_find" : rest) -> "_find" : "menger" : rest+                         _ -> "menger" : args)) { env = Just env } input+      writeIORef schemaOverride (Just inputSchemas)+      resetInput plan+      (act run' dir env port `finally` (writeIORef schemaOverride Nothing >> stopHolders dir))++-- | The items every JSON frame carried, in order.+itemsReceived :: IO [Value]+itemsReceived = do+  rs <- readIORef inputReceived+  pure (concat [ is | ReceivedJson _ is <- rs ])++-- | The seq of every @end@.+ends :: IO [Integer]+ends = do+  rs <- readIORef inputReceived+  pure [ toInteger sq | ReceivedEnd sq <- rs ]++firstSub :: [(a, b, c)] -> a+firstSub ((s, _, _) : _) = s+firstSub [] = error "no binary frame"++-- | A process's resident memory, in KiB (VmRSS).+rssKb :: String -> IO Int+rssKb pid = do+  st <- BS8.readFile ("/proc" </> pid </> "status")+  case [ l | l <- BS8.lines st, "VmRSS:" `BS8.isPrefixOf` l ] of+    (l : _) -> pure (read (BS8.unpack (BS8.words l !! 1)))+    [] -> fail ("no VmRSS for " <> pid <> ": " <> BS8.unpack st)++-- | Wait up to the seconds for the condition.+waitUntil :: Int -> IO Bool -> IO Bool+waitUntil secs cond = go (secs * 20)+  where+    go 0 = cond+    go n = cond >>= \ok -> if ok then pure True else threadDelay 50000 >> go (n - 1 :: Int)++-- | The holder processes whose socket lies under the directory.+holdersUnder :: FilePath -> IO [String]+holdersUnder dir = do+  pids <- filter (all (`elem` ['0' .. '9'])) <$> listDirectory "/proc"+  fmap concat $ mapM (\p -> do+    r <- try (BS.readFile ("/proc" </> p </> "cmdline")) :: IO (Either SomeException BS.ByteString)+    pure [ p | Right bs <- [r], let args = map BS8.unpack (BS.split 0 bs), "_hold" `elem` args, any (dir `isInfixOf`) args ]) pids++-- | Stop every holder whose socket lies under the directory.+stopHolders :: FilePath -> IO ()+stopHolders dir = do+  pids <- filter (all (`elem` ['0' .. '9'])) <$> listDirectory "/proc"+  forM_ pids $ \p -> do+    r <- try (BS.readFile ("/proc" </> p </> "cmdline")) :: IO (Either SomeException BS.ByteString)+    case r of+      Right bs | let args = map BS8.unpack (BS.split 0 bs), "_hold" `elem` args, any (dir `isInfixOf`) args ->+        signalProcess sigKILL (read p) `catchAny` \_ -> pure ()+      _ -> pure ()++catchAny :: IO a -> (SomeException -> IO a) -> IO a+catchAny act h = try act >>= either h pure
test/MockService.hs view
@@ -17,6 +17,8 @@   , ViewMode(..)   , todaysService   , withMockService+  , withEphemeralService+  , withMockServiceOn   , serveMockService   , mockCode   , mockState@@ -29,21 +31,43 @@   , renamedGet     -- * SYNAPSE-CC-8: a service serving schemas a test gives it   , schemaOverride+    -- * SYNAPSE-40: a dial that takes its time, and what is left open+  , upgradeDelay+  , connectionsOpen     -- * SYNAPSE-26: what a closed stdout cost the service   , callsMade   , floodsEnded   , dropNext+    -- * RETURNS-36: a service on the published 0.7.0 line+  , served070+    -- * RETURNS-40: a service that writes its own IR+  , irAnswer+  , irAsked+    -- * BIDIR-18: methods with an input half+  , InputPlan(..)+  , defaultInputPlan+  , inputPlan+  , inputSchemas+  , Received(..)+  , inputReceived+  , inputViolations+  , resetInput   ) where  import Control.Concurrent (forkIO, threadDelay) import Control.Concurrent.MVar (MVar, newEmptyMVar, takeMVar, tryPutMVar, tryTakeMVar) import System.Timeout (timeout)-import Control.Exception (SomeException, bracket, try)+import Control.Exception (SomeException, bracket, finally, try) import Control.Monad (forever, void) import Data.Aeson import qualified Data.Aeson.Key as K import qualified Data.Aeson.KeyMap as KM+import qualified Data.ByteString as BS import qualified Data.ByteString.Char8 as BS8+import qualified Data.ByteString.Lazy as LBS+import Data.Bits (shiftL, (.|.))+import qualified Data.Map.Strict as Map+import Data.Word (Word64) import Data.IORef import Data.Text (Text) import qualified Data.Text as T@@ -55,6 +79,8 @@ import System.IO (hFlush, stdout) import System.IO.Unsafe (unsafePerformIO) +import Plexus07 (withContract)+ data ViewMode = NoView | AnonymousView | CallerView   deriving (Eq, Show) @@ -107,6 +133,17 @@ schemaOverride :: IORef (Maybe [(Text, Value)]) schemaOverride = unsafePerformIO (newIORef Nothing) +-- | SYNAPSE-40: microseconds each service waits before it answers a+-- WebSocket upgrade (a dial under way that long).+{-# NOINLINE upgradeDelay #-}+upgradeDelay :: IORef Int+upgradeDelay = unsafePerformIO (newIORef 0)++-- | SYNAPSE-40: WebSocket connections open at the services now.+{-# NOINLINE connectionsOpen #-}+connectionsOpen :: IORef Int+connectionsOpen = unsafePerformIO (newIORef 0)+ -- | SYNAPSE-26: the method of every @menger.call@ the services took, in order. {-# NOINLINE callsMade #-} callsMade :: IORef [Text]@@ -124,6 +161,119 @@ dropNext :: IORef [Text] dropNext = unsafePerformIO (newIORef []) +-- | RETURNS-36: when set, the services answer as one on the published 0.7.0+-- line does — every schema as written (a method's @streaming@, no+-- @cardinality@), every error frame spec/05's fields alone (@message@,+-- @code@, @error_kind@ and a refusal's fields, no typed @error@).+{-# NOINLINE served070 #-}+served070 :: IORef Bool+served070 = unsafePerformIO (newIORef False)++-- | RETURNS-40: when set, the service writes its own IR: @menger.ir@+-- answers this (@{"ok": IR}@ or @{"err": …}@) as plexus 0.8's does. Unset,+-- it has no @ir@ — every 0.7.0 service — and the call fails as an unknown+-- method does.+{-# NOINLINE irAnswer #-}+irAnswer :: IORef (Maybe Value)+irAnswer = unsafePerformIO (newIORef Nothing)++-- | RETURNS-40: the params of every @menger.ir@ the services took, in order.+{-# NOINLINE irAsked #-}+irAsked :: IORef [Value]+irAsked = unsafePerformIO (newIORef [])++-- | BIDIR-18: how the services answer a call's input half.+data InputPlan = InputPlan+  { planWindow    :: Int+    -- ^ the first credit, and the window: past half of it consumed, an+    -- @ack@ and a @credit@ for what was consumed+  , planStingy    :: Bool+    -- ^ grant the first window and nothing more, never ack+  , planRefuse    :: Maybe (Int, Text)+    -- ^ at this many frames, refuse with this @error_kind@ (naming the frame)+  , planClose     :: Maybe (Int, Text)+    -- ^ at this many frames, @input_closed {reason}@, then an error item+  , planDropAfter :: Maybe Int+    -- ^ at this many frames, close the socket+  } deriving (Eq, Show)++defaultInputPlan :: InputPlan+defaultInputPlan = InputPlan 64 False Nothing Nothing Nothing++{-# NOINLINE inputPlan #-}+inputPlan :: IORef InputPlan+inputPlan = unsafePerformIO (newIORef defaultInputPlan)++-- | One input frame as a service took it.+data Received+  = ReceivedJson Word64 [Value]                 -- ^ @menger.input {seq, items}@+  | ReceivedBinary Word64 Word64 BS.ByteString  -- ^ a binary frame: subscription, seq, payload+  | ReceivedEnd Word64                          -- ^ @menger.end {seq}@+  deriving (Eq, Show)++-- | BIDIR-18: every input frame the services took, in order.+{-# NOINLINE inputReceived #-}+inputReceived :: IORef [Received]+inputReceived = unsafePerformIO (newIORef [])++-- | BIDIR-18: what a client did that spec/10 forbids (past credit, out of+-- sequence, a malformed binary frame), each said.+{-# NOINLINE inputViolations #-}+inputViolations :: IORef [Text]+inputViolations = unsafePerformIO (newIORef [])++resetInput :: InputPlan -> IO ()+resetInput plan = do+  writeIORef inputPlan plan+  writeIORef inputReceived []+  writeIORef inputViolations []++-- | BIDIR-18: a root with a @stream@ activation beside @tickets@, whose+-- methods take a stream in — for 'schemaOverride'.+inputSchemas :: [(Text, Value)]+inputSchemas =+  [ ("", object+      [ "namespace" .= ("menger" :: Text), "version" .= ("1.0.0" :: Text), "description" .= ("Central routing" :: Text)+      , "hash" .= ("r-input" :: Text)+      , "children" .=+          [ object ["namespace" .= ("tickets" :: Text), "description" .= ("Plans as data" :: Text), "hash" .= ("t0" :: Text)]+          , object ["namespace" .= ("stream" :: Text), "description" .= ("Streams in" :: Text), "hash" .= ("s0" :: Text)] ] ])+  , ("stream", object+      [ "namespace" .= ("stream" :: Text), "version" .= ("0.1.0" :: Text), "hash" .= ("s0" :: Text)+      , "description" .= ("Streams in" :: Text)+      , "methods" .=+          [ m "lines" "A log, a line an item" (object ["type" .= ("string" :: Text)]) Nothing+          , m "ingest" "Items in, an object each" item Nothing+          , m "audio" "Raw bytes in" bytesSchema Nothing+          , m "tagged" "Lines in, beside a parameter named input" (object ["type" .= ("string" :: Text)])+              (Just (object ["type" .= ("object" :: Text), "required" .= (["input"] :: [Text]), "properties" .= object+                  ["input" .= object ["type" .= ("string" :: Text), "description" .= ("A tag" :: Text)]]]))+          ] ]) ]+  where+    m :: Text -> Text -> Value -> Maybe Value -> Value+    m n d inp ps = object $+      [ "name" .= (n :: Text), "description" .= (d :: Text), "hash" .= (n <> "0")+      , "returns" .= object [], "input" .= inp ] ++ [ "params" .= p | Just p <- [ps] ]+    item = object+      [ "type" .= ("object" :: Text), "title" .= ("Item" :: Text), "required" .= (["at", "pad"] :: [Text])+      , "properties" .= object ["at" .= object ["type" .= ("integer" :: Text)], "pad" .= object ["type" .= ("string" :: Text)]] ]+    bytesSchema = object+      [ "type" .= ("string" :: Text), "contentEncoding" .= ("base64" :: Text)+      , "contentMediaType" .= ("application/octet-stream" :: Text), "x-plexus-binary" .= True ]++-- | One call's input half at the service.+data InputCall = InputCall+  { icEmit     :: Value -> IO ()+  , icMeta     :: Value+  , icMethod   :: Text+  , icNext     :: IORef Word64   -- ^ the next frame expected+  , icItems    :: IORef Int      -- ^ items taken+  , icGranted  :: IORef Int      -- ^ items granted in all+  , icUngiven  :: IORef Int      -- ^ items consumed and not yet granted back+  , icTag      :: Maybe Text     -- ^ the call's own param named input, if it had one+  , icOver     :: IORef Bool     -- ^ the half is closed (refused, closed)+  }+ mockCode, mockState :: Text mockCode = "c_19ab" mockState = "s_7f2c"@@ -138,9 +288,44 @@       N.bind sock (N.SockAddrInet 0 (N.tupleToHostAddress (127, 0, 0, 1)))       N.listen sock 16       port <- fromIntegral <$> N.socketPort sock-      void $ forkIO $ acceptLoop sc sock+      void $ forkIO $ acceptLoop (const (pure ())) sc sock       pure (sock, port) +-- | SYNAPSE-40: today's service on a port already chosen (one a stopped+-- service had: a server come back).+withMockServiceOn :: Int -> IO a -> IO a+withMockServiceOn port act = bracket open N.close (const act)+  where+    open = do+      sock <- N.socket N.AF_INET N.Stream N.defaultProtocol+      N.setSocketOption sock N.ReuseAddr 1+      N.bind sock (N.SockAddrInet (fromIntegral port) (N.tupleToHostAddress (127, 0, 0, 1)))+      N.listen sock 16+      void $ forkIO $ acceptLoop (const (pure ())) todaysService sock+      pure sock++-- | SYNAPSE-40: a service that stops as a process does. When the action+-- ends, its listener closes and so does every connection it accepted (a+-- FIN, as the kernel sends for a process that exits), so a client still+-- holding one sees its server go.+withEphemeralService :: Scenario -> (Int -> IO a) -> IO a+withEphemeralService sc act = do+  accepted <- newIORef []+  bracket (open accepted) (stop accepted) (\(_, port) -> act port)+  where+    open accepted = do+      sock <- N.socket N.AF_INET N.Stream N.defaultProtocol+      N.setSocketOption sock N.ReuseAddr 1+      N.bind sock (N.SockAddrInet 0 (N.tupleToHostAddress (127, 0, 0, 1)))+      N.listen sock 16+      port <- fromIntegral <$> N.socketPort sock+      void $ forkIO $ acceptLoop (\c -> atomicModifyIORef' accepted (\cs -> (c : cs, ()))) sc sock+      pure (sock, port)+    stop accepted (sock, _) = do+      N.close sock+      cs <- readIORef accepted+      mapM_ (\c -> void (try (N.shutdown c N.ShutdownBoth) :: IO (Either SomeException ()))) cs+ -- | Serve until killed, printing the port (for transcripts by hand). serveMockService :: Scenario -> IO () serveMockService sc = withMockService sc $ \port -> do@@ -148,33 +333,41 @@   hFlush stdout   forever (threadDelay 1000000) -acceptLoop :: Scenario -> N.Socket -> IO ()-acceptLoop sc sock = do+acceptLoop :: (N.Socket -> IO ()) -> Scenario -> N.Socket -> IO ()+acceptLoop onAccept sc sock = do   r <- try (N.accept sock)   case r of     Left (_ :: SomeException) -> pure ()   -- the service was closed     Right (conn, _) -> do+      onAccept conn       void $ forkIO $ do         _ <- try (session conn) :: IO (Either SomeException ())         N.close conn-      acceptLoop sc sock+      acceptLoop onAccept sc sock   where     session conn = do       pending <- WS.makePendingConnection conn WS.defaultConnectionOptions       -- the credential the upgrade carried, if any       let bearer = lookup "Authorization" (WS.requestHeaders (WS.pendingRequest pending))+      readIORef upgradeDelay >>= \d -> if d > 0 then threadDelay d else pure ()       c <- WS.acceptRequest pending       atomicModifyIORef' connectionsAccepted (\n -> (n + 1, ()))+      atomicModifyIORef' connectionsOpen (\n -> (n + 1, ()))       next <- newIORef (100 :: Int)-      forever $ do-        msg <- WS.receiveData c-        case decode msg of-          Just (Object o) -> handle sc c next (isBearer bearer) o-          _ -> pure ()+      inputs <- newIORef Map.empty+      flip finally (atomicModifyIORef' connectionsOpen (\n -> (n - 1, ()))) $ forever $ do+        dm <- WS.receiveDataMessage c+        case dm of+          WS.Binary bytes -> binaryInput inputs (LBS.toStrict bytes)+          WS.Text msg _ -> case decode msg of+            Just (Object o)+              | str "method" o `elem` ["menger.input", "menger.end"] -> jsonInput inputs o+              | otherwise -> handle sc c next inputs (isBearer bearer) o+            _ -> pure ()     isBearer = maybe False (BS8.isPrefixOf "Bearer ") -handle :: Scenario -> WS.Connection -> IORef Int -> Bool -> Object -> IO ()-handle sc c next signedIn o = do+handle :: Scenario -> WS.Connection -> IORef Int -> IORef (Map.Map Int InputCall) -> Bool -> Object -> IO ()+handle sc c next inputs signedIn o = do   let rid = KM.lookup "id" o       method = str "method" o       params = case KM.lookup "params" o of { Just (Object p) -> p; _ -> KM.empty }@@ -183,24 +376,35 @@     then do       atomicModifyIORef' unsubscribes (\us -> (maybe Null id (KM.lookup "params" o) : us, ()))       send (object ["jsonrpc" .= ("2.0" :: Text), "id" .= rid, "result" .= True])-    else handleCall sc c next signedIn o rid method params+    else handleCall sc c next inputs signedIn o rid method params   where     send :: Value -> IO ()     send = WS.sendTextData c . encode -handleCall :: Scenario -> WS.Connection -> IORef Int -> Bool -> Object -> Maybe Value -> Text -> Object -> IO ()-handleCall sc c next signedIn _o rid method params = do+handleCall :: Scenario -> WS.Connection -> IORef Int -> IORef (Map.Map Int InputCall) -> Bool -> Object -> Maybe Value -> Text -> Object -> IO ()+handleCall sc c next inputs signedIn _o rid method params = do   sub <- atomicModifyIORef' next (\n -> (n + 1, n))   send (object ["jsonrpc" .= ("2.0" :: Text), "id" .= rid, "result" .= sub])   let emit item = send (object         [ "jsonrpc" .= ("2.0" :: Text), "method" .= ("subscription" :: Text)         , "params" .= object ["subscription" .= sub, "result" .= item] ])       meta = object ["provenance" .= (["menger"] :: [Text]), "plexus_hash" .= ("h0" :: Text), "timestamp" .= (0 :: Int)]+      -- RETURNS-36: a schema as plexus 0.7 states it (its methods' cardinality and returns)       dat :: Text -> Value -> IO ()-      dat ct v = emit (object ["type" .= ("data" :: Text), "metadata" .= meta, "content_type" .= ct, "content" .= v])+      dat ct v = do+        as070 <- readIORef served070+        emit (object ["type" .= ("data" :: Text), "metadata" .= meta, "content_type" .= ct+                     , "content" .= (if ".schema" `T.isSuffixOf` ct && not as070 then withContract v else v)])+      -- RETURNS-36: plexus 0.7's error frame — the typed RemoteError the+      -- message names, and AUTH-2's refusal fields beside it       err :: Text -> [(K.Key, Value)] -> IO ()-      err m extra = emit (Object (KM.fromList (-        [("type", "error"), ("metadata", meta), ("message", String m), ("recoverable", Bool False)] <> extra)))+      err m extra = do+        as070 <- readIORef served070+        -- as plexus-core writes it: message, code and spec/05's fields+        -- always; 0.8's typed error beside them (the 0.7.0 line has none)+        emit (Object (KM.fromList (+          [("type", "error"), ("metadata", meta), ("message", String m), ("code", String (code070 m extra)), ("recoverable", Bool False)]+          <> [ ("error", remoteError m extra) | not as070 ] <> extra)))       done = emit (object ["type" .= ("done" :: Text), "metadata" .= meta])   case method of     "_info" -> do@@ -241,6 +445,21 @@         "tickets.ask" -> void $ forkIO (askBack emit meta dat err >> done)         -- SYNAPSE-28: a stream that never ends by itself (until unsubscribed)         "tickets.watch" -> dat "tickets.watch" (object ["type" .= ("watching" :: Text)])+        -- BIDIR-18: a method with an input half: credit at once, then the+        -- frames as they come (jsonInput, binaryInput)+        _ | inner `elem` ["stream.lines", "stream.ingest", "stream.audio", "stream.tagged"] -> do+            plan <- readIORef inputPlan+            ic <- InputCall emit meta inner <$> newIORef 0 <*> newIORef 0 <*> newIORef (planWindow plan)+                    <*> newIORef 0 <*> pure (case KM.lookup "input" args of { Just (String t) -> Just t; _ -> Nothing })+                    <*> newIORef False+            atomicModifyIORef' inputs (\m -> (Map.insert sub ic m, ()))+            emit (object ["type" .= ("credit" :: Text), "n" .= planWindow plan])+        "menger.ir" -> do+          atomicModifyIORef' irAsked (\as -> (as ++ [maybe (Object KM.empty) id (KM.lookup "params" params)], ()))+          answer <- readIORef irAnswer+          case answer of+            Just v -> dat "menger.ir" v >> done+            Nothing -> err "Method not found: menger.ir" [] >> done         "tickets.get" | renamed -> err "Method not found: tickets.get" [] >> done         "tickets.fetch" | renamed -> callMethod sc signedIn "tickets.get" args dat err >> done         "tickets.schema" | renamed, scView sc /= AnonymousView, not (scCatalog sc) ->@@ -255,6 +474,123 @@     send :: Value -> IO ()     send = WS.sendTextData c . encode +-- | BIDIR-18: a JSON input frame or end, for the call it names.+jsonInput :: IORef (Map.Map Int InputCall) -> Object -> IO ()+jsonInput inputs o = do+  let params = case KM.lookup "params" o of { Just (Object p) -> p; _ -> KM.empty }+      num k = case KM.lookup k params of { Just (Number n) -> Just (round n); _ -> Nothing }+  case (num "subscription", num "seq") of+    (Just sub, Just sq) -> do+      calls <- readIORef inputs+      case Map.lookup sub calls of+        Nothing -> violation ("a frame for no call: " <> T.pack (show sub))+        Just ic+          | str "method" o == "menger.end" -> do+              atomicModifyIORef' inputReceived (\rs -> (rs ++ [ReceivedEnd (fromIntegral sq)], ()))+              expected <- readIORef (icNext ic)+              if fromIntegral sq /= expected then violation ("end seq " <> T.pack (show sq) <> ", frames " <> T.pack (show expected)) else pure ()+              finish ic+          | otherwise -> do+              let items = case KM.lookup "items" params of { Just (Array a) -> V.toList a; _ -> [] }+              atomicModifyIORef' inputReceived (\rs -> (rs ++ [ReceivedJson (fromIntegral sq) items], ()))+              taken ic (fromIntegral sq) (length items)+    _ -> violation "an input frame without subscription or seq"++-- | BIDIR-18: a binary input frame: @[0x01][subscription u64][seq u64][payload]@.+binaryInput :: IORef (Map.Map Int InputCall) -> BS.ByteString -> IO ()+binaryInput inputs bytes+  | BS.length bytes < 17 || BS.head bytes /= 0x01 = violation "a malformed binary frame"+  | otherwise = do+      let word b = foldl (\acc x -> (acc `shiftL` 8) .|. fromIntegral x) (0 :: Word64) (BS.unpack b)+          sub = word (BS.take 8 (BS.drop 1 bytes))+          sq = word (BS.take 8 (BS.drop 9 bytes))+          payload = BS.drop 17 bytes+      atomicModifyIORef' inputReceived (\rs -> (rs ++ [ReceivedBinary sub sq payload], ()))+      calls <- readIORef inputs+      case Map.lookup (fromIntegral sub) calls of+        Nothing -> violation ("a binary frame for no call: " <> T.pack (show sub))+        Just ic -> taken ic sq 1++violation :: Text -> IO ()+violation v = atomicModifyIORef' inputViolations (\vs -> (vs ++ [v], ()))++-- | Frame @sq@ of @n@ items taken: checked against the sequence and the+-- credit, then acked and credited as the plan says.+taken :: InputCall -> Word64 -> Int -> IO ()+taken ic sq n = do+  over <- readIORef (icOver ic)+  if over then pure () else do+    plan <- readIORef inputPlan+    expected <- readIORef (icNext ic)+    if sq /= expected then violation ("out of sequence: " <> T.pack (show sq) <> ", expected " <> T.pack (show expected)) else pure ()+    writeIORef (icNext ic) (sq + 1)+    total <- atomicModifyIORef' (icItems ic) (\k -> (k + n, k + n))+    granted <- readIORef (icGranted ic)+    if total > granted then violation ("past credit: " <> T.pack (show total) <> " of " <> T.pack (show granted)) else pure ()+    let frames = fromIntegral (sq + 1)+        errorItem kind = icEmit ic (object+          [ "type" .= ("error" :: Text), "metadata" .= icMeta ic, "message" .= ("input refused: " <> kind)+          , "code" .= ("-32602" :: Text), "error_kind" .= kind, "recoverable" .= False, "seq" .= sq ])+    case (planDropAfter plan, planRefuse plan, planClose plan) of+      (Just k, _, _) | frames >= k -> ioError (userError "dropped mid-input")+      (_, Just (k, kind), _) | frames >= k -> do+        writeIORef (icOver ic) True+        icEmit ic (object ["type" .= ("input_closed" :: Text), "reason" .= kind])+        errorItem kind+      (_, _, Just (k, reason)) | frames >= k -> do+        writeIORef (icOver ic) True+        icEmit ic (object ["type" .= ("input_closed" :: Text), "reason" .= reason, "ack" .= sq])+        icEmit ic (object+          [ "type" .= ("error" :: Text), "metadata" .= icMeta ic, "message" .= ("the call was asked again: " <> reason)+          , "code" .= ("-32001" :: Text), "error_kind" .= ("unauthenticated" :: Text), "recoverable" .= False ])+      _ | planStingy plan -> pure ()+        | otherwise -> do+            ungiven <- atomicModifyIORef' (icUngiven ic) (\u -> (u + n, u + n))+            if ungiven * 2 >= planWindow plan+              then do+                writeIORef (icUngiven ic) 0+                modifyIORef' (icGranted ic) (+ ungiven)+                icEmit ic (object ["type" .= ("ack" :: Text), "seq" .= sq])+                icEmit ic (object ["type" .= ("credit" :: Text), "n" .= ungiven])+              else pure ()++-- | The input ended: the last ack, what was taken, done.+finish :: InputCall -> IO ()+finish ic = do+  n <- readIORef (icItems ic)+  next <- readIORef (icNext ic)+  if next > 0 then icEmit ic (object ["type" .= ("ack" :: Text), "seq" .= (next - 1)]) else pure ()+  icEmit ic (object+    [ "type" .= ("data" :: Text), "metadata" .= icMeta ic, "content_type" .= icMethod ic+    , "content" .= object (["type" .= ("taken" :: Text), "items" .= n, "frames" .= next] ++ [ "tag" .= t | Just t <- [icTag ic] ]) ])+  icEmit ic (object ["type" .= ("done" :: Text), "metadata" .= icMeta ic])++-- | RETURNS-36: the plexus 0.7 'RemoteError' an error the mock answers is:+-- a refusal (AUTH-2's fields) a missing grant, a "Method not found: a.m" or+-- "Activation not found: a" one of those, anything else an execution failure.+remoteError :: Text -> [(K.Key, Value)] -> Value+remoteError m extra+  | lookup "error_kind" extra == Just (String "forbidden") = object+      [ "kind" .= ("forbidden" :: Text), "reason" .= ("missing_grant" :: Text)+      , "grant" .= field "grant", "scope" .= field "scope"+      , "roles" .= maybe (toJSON ([] :: [Text])) id (lookup "roles" extra)+      , "held_on" .= maybe (toJSON ([] :: [Text])) id (lookup "held_on" extra) ]+  | Just rest <- T.stripPrefix "Method not found: " m+  , (a, dm) <- T.breakOnEnd "." rest, not (T.null a) = object+      [ "kind" .= ("method_not_found" :: Text), "activation" .= T.dropEnd 1 a, "method" .= dm ]+  | Just a <- T.stripPrefix "Activation not found: " m = object+      [ "kind" .= ("activation_not_found" :: Text), "activation" .= a ]+  | otherwise = object [ "kind" .= ("execution" :: Text), "message" .= m ]+  where+    field k = maybe (String "") id (lookup k extra)++-- | The JSON-RPC code a service puts on an error frame (spec/05).+code070 :: Text -> [(K.Key, Value)] -> Text+code070 m extra+  | lookup "error_kind" extra == Just (String "forbidden") = "-32003"+  | "not found" `T.isInfixOf` m = "-32601"+  | otherwise = "-32000"+ -- | SCAFFOLD-21: the answers to 'askBack's requests (@menger.respond@'s -- @response_data@), as they arrive on any connection. {-# NOINLINE responses #-}@@ -299,7 +635,10 @@             -> (Text -> Value -> IO ()) -> (Text -> [(K.Key, Value)] -> IO ()) -> IO () callMethod' sc _signedIn inner args dat err =  case inner of-  "menger.schema" -> dat "menger.schema" (rootSchema sc)+  "menger.schema" -> do+    -- RETURNS-40: a service that writes its IR lists `ir` among its own methods+    answer <- readIORef irAnswer+    dat "menger.schema" (maybe id (const withIr) answer (rootSchema sc))   "tickets.schema" | scCatalog sc -> do       Just (Object o) <- decodeFileStrict "test/fixtures/find-tickets.json"       dat "tickets.schema" (Object (catalogView sc o))@@ -370,6 +709,16 @@   _ <- NB.recv sock' 4096   N.close sock' +-- | RETURNS-40: a root schema with the `ir` method beside its own.+withIr :: Value -> Value+withIr (Object o) = Object (KM.insert "methods" (Array (V.snoc ms irMethod)) o)+  where+    ms = case KM.lookup "methods" o of { Just (Array a) -> a; _ -> mempty }+    irMethod = object+      [ "name" .= ("ir" :: Text), "description" .= ("The service's IR" :: Text), "hash" .= ("ir0" :: Text)+      , "cardinality" .= ("one" :: Text), "returns" .= object [] ]+withIr v = v+ rootSchema :: Scenario -> Value rootSchema sc = object $   [ "namespace" .= ("menger" :: Text), "version" .= ("1.0.0" :: Text)@@ -395,7 +744,7 @@   , "methods" .= [ m "providers" "The ways in this server offers"                  , m "login" "Sign in \x2014 a person through the browser, an agent with its key"                  , m "signup" "Create an account, or ask for access" ] ]-  where m n d = object ["name" .= (n :: Text), "description" .= (d :: Text), "hash" .= (n <> "0"), "public" .= True]+  where m n d = object ["name" .= (n :: Text), "description" .= (d :: Text), "hash" .= (n <> "0"), "public" .= True, "returns" .= object []]  ticketsSchema :: Scenario -> Value ticketsSchema sc = object $@@ -409,7 +758,7 @@   where     caller = scView sc == CallerView     method (n, d, held, target) = object $-      [ "name" .= (n :: Text), "description" .= (d :: Text), "hash" .= (n <> "0")+      [ "name" .= (n :: Text), "description" .= (d :: Text), "hash" .= (n <> "0"), "returns" .= object []       , "params" .= object ["type" .= ("object" :: Text), "required" .= (["id"] :: [Text]),           "properties" .= object ["id" .= object ["type" .= ("string" :: Text), "description" .= ("Ticket id, e.g. MNG-2" :: Text)]]] ]       <> (if caller then [ "grant" .= ("tickets." <> n), "held_on" .= (held :: [Text]) ] <> maybe [] (\t -> ["target" .= (t :: Text)]) target else [])
test/OutputSpec.hs view
@@ -55,7 +55,7 @@ outputSpec exe = describe "synapse -o FILE (SYNAPSE-39)" $ do   it "is this build" $ do     (_, out, _) <- readProcessWithExitCode exe ["--version"] ""-    out `shouldStartWith` "3.17.3"+    out `shouldStartWith` "4.0.0"    forM_ [ ([], "", ["tickets", "get", "--id", "MENGER-12"])         , (["-j"], " with -j", ["tickets", "get", "--id", "MENGER-12"])@@ -211,6 +211,43 @@       err `shouldContain` "tickets.set_status"       doesFileExist file >>= (`shouldBe` False)       leftovers dir "answer.out" >>= (`shouldBe` [])++  -- RETURNS-36: synapse 4.0 reads the published 0.7.0 line too — every+  -- server running today: its methods' streaming flags, its error frames'+  -- spec/05 fields+  describe "against a service on the published 0.7.0 line" $ do+    let as070 act = do+          writeIORef served070 True+          r <- try act+          writeIORef served070 False+          either (\(e :: SomeException) -> ioError (userError (show e))) pure r+    it "an answer, and the same in FILE" $ as070 $+      withRun $ \dir spawn -> do+        (code0, onStdout, err0) <- reading (spawn []) ["tickets", "get", "--id", "MENGER-12"]+        (code0, err0) `shouldBe` (ExitSuccess, "")+        onStdout `shouldContain` "MENGER-12"+        let file = dir </> "answer.out"+        (code, out, err) <- reading (spawn ["-o", file]) ["tickets", "get", "--id", "MENGER-12"]+        (code, out, err) `shouldBe` (ExitSuccess, "", "")+        readFile file >>= (`shouldBe` onStdout)+    it "a stream's items in FILE (its streaming flag says many)" $ as070 $+      withRun $ \dir spawn -> do+        let file = dir </> "answer.out"+        (Just inp, Just out, Just err, ph) <- createProcess (spawn ["-j", "-o", file] ["tickets", "flood"])+        hClose inp+        got <- waitUpTo 10 (doesFileExist file >>= \e -> if e then ("\"tick\"" `isInfixOf`) <$> readFile' file else pure False)+        terminateProcess ph+        _ <- timeout 5000000 (waitForProcess ph)+        _ <- BS.hGetContents out+        _ <- BS.hGetContents err+        got `shouldBe` True+    it "a refusal: exit 77, the grant said" $ as070 $+      withRunS (Scenario CallerView False True mockState False) $ \dir spawn -> do+        let file = dir </> "answer.out"+        (code, out, err) <- reading (spawn ["-j", "-o", file]) ["tickets", "set_status", "--id", "IDENTITY-3"]+        (code, out) `shouldBe` (ExitFailure 77, "")+        err `shouldContain` "tickets.set_status"+        doesFileExist file >>= (`shouldBe` False)    it "a two-way request's line goes to stderr, its answer to FILE" $     withRun $ \dir spawn -> do
test/ParseSpec.hs view
@@ -55,7 +55,7 @@    describe "buildParamValue with arrays" $ do     let emptyIR = IR-          { irVersion = "2.0"+          { irVersion = "3.0"           , irHash = Nothing           , irTypes = Map.empty           , irMethods = Map.empty@@ -343,9 +343,11 @@   , mdFullPath = "t.create"   , mdNamespace = "t"   , mdDescription = Nothing-  , mdStreaming = False   , mdParams = params   , mdReturns = RefJson+  , mdError = Nothing+  , mdProgress = Nothing+  , mdCardinality = CardinalityOne   , mdBidirType = Nothing   , mdBidirResponseType = Nothing   , mdBidirResponseSchema = Nothing
test/PipeSpec.hs view
@@ -47,7 +47,7 @@ pipeSpec exe = describe "a closed stdout (SYNAPSE-26)" $ do   it "is this build" $ do     (_, out, _) <- readProcessWithExitCode exe ["--version"] ""-    out `shouldStartWith` "3.17"+    out `shouldStartWith` "4.0"    -- one reader takes a byte and closes, one closes before anything is   -- printed
+ test/Plexus07.hs view
@@ -0,0 +1,88 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE LambdaCase #-}++-- | RETURNS-36: the test fixtures captured from 0.6-shaped services (menger's+-- schemas of 2026-10-15 and 2026-10-29, the mock service's own), as plexus+-- 0.7 states them.+--+-- plexus 0.7 (RETURNS-1) states a method's cardinality where 0.6 had a+-- streaming flag, and synapse reads 0.7 only. The fixtures stay as they+-- were captured; 'asPlexus07' says each method's @cardinality@ —+-- @"many"@ where it said @streaming: true@, else @"one"@ — and drops the+-- flag. Nothing else moves: a method with no @returns@ stays without one+-- (and is no 0.7 schema).+module Plexus07+  ( asPlexus07+  , methodAs07+  , withContract+  , methodWithContract+  , renameParamsDef+  ) where++import Data.Aeson (Value(..))+import qualified Data.Aeson.Key as K+import qualified Data.Aeson.KeyMap as KM+import Data.Text (Text)++-- | A plugin schema — or a fixture's rows (@[{"path", "schema"}]@) — with+--   every method as 0.7 states it, its children's too.+asPlexus07 :: Value -> Value+asPlexus07 = \case+  Object o -> Object (KM.mapWithKey key o)+  Array a -> Array (fmap asPlexus07 a)+  v -> v+  where+    key k v = case (k, v) of+      ("methods", Array ms) -> Array (fmap methodAs07 ms)+      ("schema", _) -> asPlexus07 v+      ("children", Array cs) -> Array (fmap asPlexus07 cs)+      _ -> v++-- | One method schema as 0.7 states it.+methodAs07 :: Value -> Value+methodAs07 = \case+  Object m | not (KM.member "cardinality" m) ->+    let card = if KM.lookup "streaming" m == Just (Bool True) then "many" else "one"+    in Object (KM.insert "cardinality" (String card) (KM.delete "streaming" m))+  Object m -> Object (KM.delete "streaming" m)+  v -> v++-- | 'asPlexus07', and each method that states no @returns@ (a fixture+--   written for a test of something else: auth's fields, help) given+--   @returns: {}@ — any JSON — as a 0.7 schema must state one.+withContract :: Value -> Value+withContract = \case+  Object o -> Object (KM.mapWithKey key o)+  Array a -> Array (fmap withContract a)+  v -> v+  where+    key k v = case (k, v) of+      ("methods", Array ms) -> Array (fmap methodWithContract ms)+      ("schema", _) -> withContract v+      ("children", Array cs) -> Array (fmap withContract cs)+      _ -> v++-- | One method as 'withContract' states it.+methodWithContract :: Value -> Value+methodWithContract v = case methodAs07 v of+  Object m | not (KM.member "returns" m) -> Object (KM.insert "returns" (Object KM.empty) m)+  m -> m++-- | In every method's params of a plugin schema, the @$defs@ entry @from@+--   renamed @to@, and every @$ref@ to it.+renameParamsDef :: Text -> Text -> Value -> Value+renameParamsDef from to = \case+  Object o -> Object (KM.mapWithKey (\k v -> if k == "methods" then onMethods v else v) o)+  v -> v+  where+    onMethods (Array ms) = Array (fmap onMethod ms)+    onMethods v = v+    onMethod (Object m) = Object (KM.mapWithKey (\k v -> if k == "params" then rename v else v) m)+    onMethod v = v+    fromRef = String ("#/$defs/" <> from)+    rename = \case+      Object o -> Object (KM.fromList [ (key k, rename v) | (k, v) <- KM.toList o ])+      Array a -> Array (fmap rename a)+      v | v == fromRef -> String ("#/$defs/" <> to)+      v -> v+    key k = if k == K.fromText from then K.fromText to else k
test/ReconnectSpec.hs view
@@ -34,7 +34,7 @@ import Test.Hspec  import Plexus.Client (SubstrateConfig(..))-import Plexus.Schema.Recursive (MethodSchema(..), MethodRole(..))+import Plexus.Schema.Recursive (Cardinality(..), MethodSchema(..), MethodRole(..)) import qualified Plexus.Transport as ST import qualified Plexus.Types as PT import Synapse.Reconnect@@ -151,7 +151,8 @@ method :: Text -> Maybe Text -> Maybe Value -> MethodSchema method name grp params = MethodSchema   { methodName = name, methodDescription = "", methodHash = "h", methodParams = params-  , methodReturns = Nothing, methodStreaming = False, methodBidirectional = False+  , methodReturns = Object mempty, methodError = Nothing, methodProgress = Nothing+  , methodCardinality = CardinalityOne, methodBidirectional = False, methodHttpMethod = "POST"   , methodRequestType = Nothing, methodResponseType = Nothing, methodDeprecation = Nothing   , methodParamSchemas = Nothing, methodRole = MethodRoleRpc, methodLongDescription = Nothing   , methodGrant = Nothing, methodTarget = Nothing, methodHeldOn = Nothing, methodPublic = False@@ -216,7 +217,7 @@       withService [Answer] $ \h -> do         sock <- holderSocket         acc <- newIORef (0 :: Int)-        Hold.holderCall sock (cfgFor (servicePort h)) (pure ()) "t.call" (object ["method" .= ("t.m" :: Text)])+        Hold.holderCall sock "test" (cfgFor (servicePort h)) (pure ()) "t.call" (object ["method" .= ("t.m" :: Text)])           (\i -> if isData i then modifyIORef' acc (+ 1) else pure ())         readIORef acc `shouldReturn` 1     outageSpec@@ -365,11 +366,11 @@ throughHolder :: IO () throughHolder = do   sock <- holderSocket-  _ <- forkIO $ Hold.runHolder (Hold.HoldOpts sock 600) `catch'` ()+  _ <- forkIO $ Hold.runHolder (Hold.holdOpts sock 600) `catch'` ()   let wait (0 :: Int) = pure ()       wait n = doesFileExist sock >>= \ok -> if ok then pure () else threadDelay 20000 >> wait (n - 1)   wait 250-  _ <- Hold.installRouter Hold.RouterOpts { Hold.roHold = Just (const (pure (Just sock))), Hold.roHoldFor = 600 }+  _ <- Hold.installRouter Hold.RouterOpts { Hold.roHold = Just (const (pure (Just "test"))), Hold.roSocket = sock, Hold.roHoldFor = 600, Hold.roVersion = "" }   pure ()   where     catch' act v = void (try act :: IO (Either SomeException ())) >> pure v
test/RenderSpec.hs view
@@ -13,7 +13,7 @@ -- service that says none of the new fields still renders sensibly. module Main (main) where -import Data.Aeson (Value)+import Data.Aeson (Value(..)) import Data.Aeson.QQ.Simple (aesonQQ) import qualified Data.Map.Strict as Map import Data.Maybe (fromJust)@@ -22,7 +22,7 @@ import qualified Data.Text.IO as TIO import Test.Hspec -import Plexus.Schema.Recursive (ChildSummary(..), MethodExample(..), MethodRole(..), MethodSchema(..), PluginSchema(..))+import Plexus.Schema.Recursive (Cardinality(..), ChildSummary(..), MethodExample(..), MethodRole(..), MethodSchema(..), PluginSchema(..)) import Synapse.Algebra.Render (Listing(..), renderListing, renderSchema, defaultStyle, summarize, fitText) import Synapse.CLI.Help (MethodPage(..), renderMethodPage, renderExample, shellQuote) import Synapse.IR.Builder (extractMethodDef)@@ -31,7 +31,8 @@ method :: Text -> Text -> Maybe Value -> MethodSchema method name desc params = MethodSchema   { methodName = name, methodDescription = desc, methodHash = "h", methodParams = params-  , methodReturns = Nothing, methodStreaming = True, methodBidirectional = False+  , methodReturns = Object mempty, methodError = Nothing, methodProgress = Nothing+  , methodCardinality = CardinalityMany, methodBidirectional = False, methodHttpMethod = "POST"   , methodRequestType = Nothing, methodResponseType = Nothing, methodDeprecation = Nothing   , methodParamSchemas = Nothing, methodRole = MethodRoleRpc   , methodLongDescription = Nothing, methodGrant = Nothing, methodTarget = Nothing, methodHeldOn = Nothing, methodPublic = False@@ -127,7 +128,7 @@  pageOf :: Bool -> MethodSchema -> (MethodDef, Text) pageOf caller ms =-  let (types, md) = extractMethodDef "tickets" "tickets" ms+  let (types, md) = either (error . show) id (extractMethodDef "tickets" "tickets" ms)       ir = emptyIR { irMethods = Map.singleton (mdFullPath md) md, irTypes = types }       page = MethodPage         { mpProgram = "synapse -P 4455", mpTarget = "menger tickets " <> methodName ms@@ -143,7 +144,7 @@               "properties": {"id": {"type": "string", "description": "Ticket id, e.g. MNG-2"},                              "status": {"type": "string", "description": "New status"}},               "required": ["id", "status"]}|]))-      (types, md) = extractMethodDef "tickets" "tickets" old+      (types, md) = either (error . show) id (extractMethodDef "tickets" "tickets" old)       ir = emptyIR { irMethods = Map.singleton (mdFullPath md) md, irTypes = types }       page = MethodPage { mpProgram = "synapse", mpTarget = "menger tickets set_status", mpSchema = Nothing, mpCaller = False }   in renderMethodPage page ir md
test/Req8RenderSpec.hs view
@@ -35,6 +35,7 @@   , psVersion         = "1.0.0"   , psDescription     = "Test service"   , psLongDescription = Nothing+  , psSelfHash        = Nothing   , psHash            = "h1"   , psMethods         = [method1]   , psChildren        = Nothing@@ -47,9 +48,12 @@       , methodDescription   = "List things"       , methodHash          = "h2"       , methodParams        = Just paramsSchema-      , methodReturns       = Nothing-      , methodStreaming     = False+      , methodReturns       = [aesonQQ|{}|]+      , methodError         = Nothing+      , methodProgress      = Nothing+      , methodCardinality   = Plexus.Schema.Recursive.CardinalityOne       , methodBidirectional = False+      , methodHttpMethod    = "POST"       , methodRequestType   = Nothing       , methodResponseType  = Nothing       , methodDeprecation   = Nothing@@ -96,7 +100,7 @@         putStrLn $ "  [FAIL] " <> name <> ": " <> reason         IORef.modifyIORef' failures (+ 1) -  let (types, md) = extractMethodDef "svc" "svc" (head (psMethods fixture))+  let (types, md) = either (error . show) id (extractMethodDef "svc" "svc" (head (psMethods fixture)))       ir = emptyIR { irMethods = Map.singleton (mdFullPath md) md, irTypes = types }       out = renderMethodHelp ir md