diff --git a/go/buscalls/buscalls_test.go b/go/buscalls/buscalls_test.go new file mode 100644 index 00000000..1779aa6e --- /dev/null +++ b/go/buscalls/buscalls_test.go @@ -0,0 +1,28 @@ +package buscalls + +import ( + "encoding/json" + "testing" +) + +// Design 47 G §587, §622: the ledger's upkeep calls have their requests and answers, and every family its word. +func TestTheLedgerUpkeepCallsAndTheirWords(t *testing.T) { + var removal LedgerRemoveRequest + if err := json.Unmarshal([]byte(`{"ledger":"records","instance":"r1"}`), &removal); err != nil || removal.Ledger != "records" || removal.Instance != "r1" { + t.Errorf("the removal read as %+v (%v)", removal, err) + } + raw, _ := json.Marshal(LedgerRemoved{Instance: "r1", Operations: 3, Bytes: 900}) + if string(raw) != `{"instance":"r1","operations":3,"bytes":900}` { + t.Errorf("the removal's answer reads %s", raw) + } + raw, _ = json.Marshal(Fill{Count: 2, Bytes: 10, MaxBytes: 100}) + if string(raw) != `{"count":2,"bytes":10,"max_bytes":100}` || LedgerFill != "mesh/ledger.fill" || LedgerRemove != "mesh/ledger.remove" { + t.Errorf("the fill reads %s, calls %s %s", raw, LedgerFill, LedgerRemove) + } + for word, want := range map[string]string{BusStateCalls: "bus-state-calls", LedgerCalls: "ledger-calls", + LedgerUpkeepCalls: "ledger-upkeep-calls", SentObjectsCalls: "sent-objects-calls"} { + if word != want { + t.Errorf("the word %q is %q", want, word) + } + } +} diff --git a/go/capabilities.go b/go/capabilities.go new file mode 100644 index 00000000..bc66d951 --- /dev/null +++ b/go/capabilities.go @@ -0,0 +1,87 @@ +package stdio + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "slices" + "sync" + "time" +) + +// The call a bundle reads its tool runner's capability words with, and the words of the families that sit beside +// `mesh/publish` and `mesh/subscribe` (design 47 G §576, §622). The words of the bus-state families are in buscalls. +const ( + // CapabilitiesCall is answered with {words}: the capability words the tool runner states. + CapabilitiesCall = "mesh/capabilities" + // CapPutSessions is the put and get sessions' word (§457, §518), CapFindingCalls the acknowledged publish and the + // subscription for new messages the finding writer uses (§559). CapSeatSettleDelay (seat.go) is the third. + CapPutSessions = "put-sessions" + CapFindingCalls = "finding-calls" +) + +// capabilityWords are the words the tool runner states, read once at the first call that needs one and kept for as long +// as the bundle runs: a bundle lives no longer than the tool runner that started it (design 47 G §576). +type capabilityWords struct { + mu sync.Mutex + read bool + words []string +} + +var capabilities capabilityWords + +// get answers the words, asking once with ask. A tool runner of before G answers `mesh/capabilities` as an unknown call, +// which reads as no word stated; any other failure is returned and asked again at the next call. +func (c *capabilityWords) get(ask func() (json.RawMessage, error)) ([]string, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.read { + return c.words, nil + } + raw, err := ask() + switch { + case errors.Is(err, errors.ErrUnsupported): + c.words = nil + case err != nil: + return nil, fmt.Errorf("reading the tool runner's capability words: %w", err) + default: + var a struct { + Words []string `json:"words"` + } + if err := json.Unmarshal(raw, &a); err != nil { + return nil, fmt.Errorf("the tool runner's capability words: %w", err) + } + c.words = a.Words + } + c.read = true + return c.words, nil +} + +// require is nil when word is among the stated ones, else an error naming the word and the node (design 47 G §488, §576). +func (c *capabilityWords) require(word string, ask func() (json.RawMessage, error)) error { + words, err := c.get(ask) + if err != nil { + return err + } + if slices.Contains(words, word) { + return nil + } + node := os.Getenv("MESH_NODE") + if node == "" { + node = "unknown" + } + return fmt.Errorf("the tool runner of node %s does not state the capability word %q: a call of its family is refused until it does", node, word) +} + +// RequireCapability fails at once, naming the word and the node, when the tool runner this bundle runs under does not +// state word; it reads the words once (design 47 G §488, §576). +func RequireCapability(word string) error { + return capabilities.require(word, func() (json.RawMessage, error) { + c := awaitChannel() + if c == nil { + return nil, errors.New("a capability is read through the runtime that launched the bundle, and Serve has not run") + } + return c.ask(CapabilitiesCall, map[string]any{}, 30*time.Second) + }) +} diff --git a/go/capabilities_test.go b/go/capabilities_test.go new file mode 100644 index 00000000..60532202 --- /dev/null +++ b/go/capabilities_test.go @@ -0,0 +1,37 @@ +package stdio + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +// Design 47 G §576: the words are read once, an unknown-call answer reads as none, and a word not stated fails naming it +// and the node (from MESH_NODE). +func TestACapabilityWordIsReadOnceAndNamedWhenNotStated(t *testing.T) { + t.Setenv("MESH_NODE", "ace") + asked := 0 + var c capabilityWords + ask := func() (json.RawMessage, error) { asked++; return json.RawMessage(`{"words":["put-sessions"]}`), nil } + if err := c.require(CapPutSessions, ask); err != nil { + t.Fatal(err) + } + err := c.require(CapFindingCalls, ask) + if err == nil || !strings.Contains(err.Error(), CapFindingCalls) || !strings.Contains(err.Error(), "ace") || asked != 1 { + t.Errorf("a word not stated: %v after %d asks", err, asked) + } + var old capabilityWords + unknown := func() (json.RawMessage, error) { + asked++ + return nil, &refused{message: "no mesh/capabilities", code: CodeNoMethod} + } + if err := old.require(CapPutSessions, unknown); err == nil || !strings.Contains(err.Error(), CapPutSessions) { + t.Errorf("a tool runner of before G stated %v", err) + } + var failing capabilityWords + if err := failing.require(CapPutSessions, func() (json.RawMessage, error) { return nil, errors.New("pipe closed") }); err == nil || + !strings.Contains(err.Error(), "pipe closed") || failing.read { + t.Errorf("a failed read: %v, kept %v", err, failing.read) + } +}