|
| 1 | +------------------------------ MODULE Invoker ------------------------------ |
| 2 | +\* SPDX-License-Identifier: MPL-2.0 |
| 3 | +\* Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> |
| 4 | +(***************************************************************************) |
| 5 | +(* Formal model of `BojRest.Invoker` *) |
| 6 | +(* (elixir/lib/boj_rest/invoker.ex). *) |
| 7 | +(* *) |
| 8 | +(* Invoker is a STATELESS, SYNCHRONOUS dispatcher. Every call to *) |
| 9 | +(* Invoker.invoke/4 either short-circuits immediately (CLI binary not *) |
| 10 | +(* found → :cli_missing) or blocks on `System.cmd/3` until the `boj-invoke` *) |
| 11 | +(* OS subprocess exits. There is no GenServer, no pool, and no per- *) |
| 12 | +(* invocation timeout in the current Phase 2 implementation (ADR-0005). *) |
| 13 | +(* Because `System.cmd` is blocking, the caller's Erlang process is the *) |
| 14 | +(* only thing waiting — no shared mailbox, no shared ETS, no shared port. *) |
| 15 | +(* *) |
| 16 | +(* The headline property is ISOLATION: for any two concurrent invocations *) |
| 17 | +(* r1 ≠ r2, the outcome of r1 is entirely independent of r2. This is *) |
| 18 | +(* structurally guaranteed because every action in Next touches exactly one *) |
| 19 | +(* request id; DoneOnce and Consistent confirm no request is resolved twice. *) |
| 20 | +(* *) |
| 21 | +(* Exit-code → result classification (boj_invoke_cli.zig contract): *) |
| 22 | +(* CLI not found → cli_missing (no subprocess spawned) *) |
| 23 | +(* exit 0 + valid JSON → ok *) |
| 24 | +(* exit 0 + invalid JSON → cli_crashed (JSON parse failure) *) |
| 25 | +(* exit 2 → args_err *) |
| 26 | +(* exit 3 → open_err *) |
| 27 | +(* exit 4 → missing_symbol_err *) |
| 28 | +(* exit 5 → init_failed_err *) |
| 29 | +(* exit other → cli_crashed *) |
| 30 | +(* *) |
| 31 | +(* Note on timeouts: the current code has NO per-invocation timeout. *) |
| 32 | +(* `System.cmd` blocks indefinitely if the subprocess hangs. ADR-0005 *) |
| 33 | +(* specifies 5 s (future pool) but the Phase 2 skeleton does not implement *) |
| 34 | +(* it. The liveness property below (EventuallyDone) therefore carries an *) |
| 35 | +(* [ASSUMED] tag: it holds only if the `boj-invoke` subprocess eventually *) |
| 36 | +(* exits on its own. See README.adoc §"Known gaps". *) |
| 37 | +(***************************************************************************) |
| 38 | +EXTENDS Naturals, FiniteSets |
| 39 | + |
| 40 | +CONSTANTS Requests \* finite set of opaque invocation ids |
| 41 | + |
| 42 | +Results == {"none", "ok", "cli_missing", "cli_crashed", |
| 43 | + "args_err", "open_err", "missing_symbol_err", "init_failed_err"} |
| 44 | + |
| 45 | +VARIABLES |
| 46 | + status, \* [Requests -> {"new", "running", "done"}] |
| 47 | + result, \* [Requests -> Results] |
| 48 | + doneCount \* [Requests -> 0..2] (double-resolution detector) |
| 49 | + |
| 50 | +vars == <<status, result, doneCount>> |
| 51 | + |
| 52 | +TypeOK == |
| 53 | + /\ status \in [Requests -> {"new", "running", "done"}] |
| 54 | + /\ result \in [Requests -> Results] |
| 55 | + /\ doneCount \in [Requests -> 0..2] |
| 56 | + |
| 57 | +Init == |
| 58 | + /\ status = [r \in Requests |-> "new"] |
| 59 | + /\ result = [r \in Requests |-> "none"] |
| 60 | + /\ doneCount = [r \in Requests |-> 0] |
| 61 | + |
| 62 | +(*-------------------------- DISPATCH ACTIONS ----------------------------*) |
| 63 | + |
| 64 | +\* Invoker.run/4 finds the CLI binary → spawns subprocess via System.cmd. |
| 65 | +\* Moves the request from "new" to "running" (subprocess is now live). |
| 66 | +Invoke(r) == |
| 67 | + /\ status[r] = "new" |
| 68 | + /\ status' = [status EXCEPT ![r] = "running"] |
| 69 | + /\ UNCHANGED <<result, doneCount>> |
| 70 | + |
| 71 | +\* Invoker.cli_path() returns nil → immediate short-circuit, no subprocess. |
| 72 | +\* The request goes from "new" directly to "done". |
| 73 | +CliMissing(r) == |
| 74 | + /\ status[r] = "new" |
| 75 | + /\ status' = [status EXCEPT ![r] = "done"] |
| 76 | + /\ result' = [result EXCEPT ![r] = "cli_missing"] |
| 77 | + /\ doneCount' = [doneCount EXCEPT ![r] = @ + 1] |
| 78 | + |
| 79 | +(*-------------------------- RESOLUTION ACTIONS --------------------------*) |
| 80 | + |
| 81 | +\* The single guarded resolution path: status[r] = "running" guard + |
| 82 | +\* atomic move to "done" disables all competing resolution actions. |
| 83 | +\* (The subprocess can only exit once; the Erlang process unblocks once.) |
| 84 | +Resolve(r, kind) == |
| 85 | + /\ status[r] = "running" |
| 86 | + /\ status' = [status EXCEPT ![r] = "done"] |
| 87 | + /\ result' = [result EXCEPT ![r] = kind] |
| 88 | + /\ doneCount' = [doneCount EXCEPT ![r] = @ + 1] |
| 89 | + |
| 90 | +\* exit 0 + Jason.decode succeeds → {:ok, map} |
| 91 | +RespondOk(r) == Resolve(r, "ok") |
| 92 | + |
| 93 | +\* exit 0 + Jason.decode fails, OR exit code not in {2,3,4,5} → :cli_crashed |
| 94 | +RespondCliCrashed(r) == Resolve(r, "cli_crashed") |
| 95 | + |
| 96 | +\* exit 2 → :args |
| 97 | +RespondArgs(r) == Resolve(r, "args_err") |
| 98 | + |
| 99 | +\* exit 3 → :open |
| 100 | +RespondOpen(r) == Resolve(r, "open_err") |
| 101 | + |
| 102 | +\* exit 4 → :missing_symbol |
| 103 | +RespondMissingSym(r) == Resolve(r, "missing_symbol_err") |
| 104 | + |
| 105 | +\* exit 5 → :init_failed |
| 106 | +RespondInitFailed(r) == Resolve(r, "init_failed_err") |
| 107 | + |
| 108 | +Next == |
| 109 | + \E r \in Requests : |
| 110 | + Invoke(r) \/ CliMissing(r) \/ |
| 111 | + RespondOk(r) \/ RespondCliCrashed(r) \/ RespondArgs(r) \/ |
| 112 | + RespondOpen(r) \/ RespondMissingSym(r) \/ RespondInitFailed(r) |
| 113 | + |
| 114 | +\* [ASSUMED] Each subprocess eventually exits on its own (no timeout in |
| 115 | +\* Phase 2; the outer Cowboy connection timeout is the de-facto bound). |
| 116 | +\* WF_vars on the disjunction of all resolution actions captures: once |
| 117 | +\* a subprocess is running, some resolution action eventually fires. |
| 118 | +Spec == |
| 119 | + /\ Init /\ [][Next]_vars |
| 120 | + /\ \A r \in Requests : |
| 121 | + WF_vars(RespondOk(r) \/ RespondCliCrashed(r) \/ RespondArgs(r) \/ |
| 122 | + RespondOpen(r) \/ RespondMissingSym(r) \/ RespondInitFailed(r)) |
| 123 | + |
| 124 | +(*-------------------------------- SAFETY --------------------------------*) |
| 125 | + |
| 126 | +\* Each invocation resolves at most once (no double System.cmd reply). |
| 127 | +DoneOnce == \A r \in Requests : doneCount[r] <= 1 |
| 128 | + |
| 129 | +Consistent == |
| 130 | + /\ \A r \in Requests : (status[r] = "running") => (result[r] = "none") |
| 131 | + /\ \A r \in Requests : (status[r] = "done") => (result[r] # "none") |
| 132 | + /\ \A r \in Requests : (status[r] = "new") => (result[r] = "none") |
| 133 | + |
| 134 | +(*------------------------------ LIVENESS --------------------------------*) |
| 135 | + |
| 136 | +\* [ASSUMED] Once a subprocess is spawned, it eventually exits and the |
| 137 | +\* invocation resolves. Relies on well-behaved CLI + WF above. |
| 138 | +EventuallyDone == |
| 139 | + \A r \in Requests : (status[r] = "running") ~> (status[r] = "done") |
| 140 | + |
| 141 | +(*------------------------ SANITY CONTROLS (non-vacuity) ----------------*) |
| 142 | +\* Each of these is EXPECTED TO BE VIOLATED when checked as an invariant. |
| 143 | +\* TLC refutes them with short witness traces, proving every result kind is |
| 144 | +\* genuinely reachable. They are NOT in Invoker.cfg. |
| 145 | +ReachOk == \A r \in Requests : result[r] # "ok" |
| 146 | +ReachCliMissing == \A r \in Requests : result[r] # "cli_missing" |
| 147 | +ReachCliCrashed == \A r \in Requests : result[r] # "cli_crashed" |
| 148 | +ReachArgs == \A r \in Requests : result[r] # "args_err" |
| 149 | +ReachOpen == \A r \in Requests : result[r] # "open_err" |
| 150 | +ReachMissingSym == \A r \in Requests : result[r] # "missing_symbol_err" |
| 151 | +ReachInitFailed == \A r \in Requests : result[r] # "init_failed_err" |
| 152 | + |
| 153 | +============================================================================ |
0 commit comments