feat(plugins): add retained event pub/sub over IPC (#303)
* feat(ipc): add retained event subscriptions * feat(plugins): expose namespaced event publishing * feat(app): wire plugin events to IPC * docs(plugins): document local event pubsub * fix(ipc): clear request deadline for subscriptions * fix(luaplugin): install event publisher before plugins load * fix(luaplugin): clear retained events after publishers stop * fix(ipc): keep event sequence gap-free when retention is rejected * fix(luaplugin): break table cycles and cap depth in Lua value conversion * docs(plugins): document conversion limits and subscription stream rules * refactor(ipc): report a missing server with a sentinel error * fix(luaplugin): fold event namespace into one topic segment * docs(site): mention plugin event publishing and subscriptions * fix(luaplugin): keep event namespaces unique across plugins * fix(luaplugin): release event namespace by installed filename * refactor(ipc): render the not-running message in the command layer * docs(plugins): clarify namespace prefixes and JSON conversion limits
This commit is contained in:
+1
-1
@@ -801,7 +801,7 @@ func visStreamCommand() *cli.Command {
|
||||
if fps > 60 {
|
||||
fps = 60
|
||||
}
|
||||
return ipc.StreamBands(ctx, ipc.DefaultSocketPath(), time.Second/time.Duration(fps), os.Stdout)
|
||||
return userIPCError(ipc.StreamBands(ctx, ipc.DefaultSocketPath(), time.Second/time.Duration(fps), os.Stdout))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -233,3 +235,17 @@ func TestDaemonClearsHistory(t *testing.T) {
|
||||
t.Fatalf("history after clear = %#v, err=%v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The ipc package returns a bare sentinel; the CLI wording is added here.
|
||||
func TestUserIPCErrorRendersNotRunning(t *testing.T) {
|
||||
rendered := userIPCError(fmt.Errorf("dial: %w", ipc.ErrNotRunning))
|
||||
want := fmt.Sprintf("cliamp is not running (no socket at %s)", ipc.DefaultSocketPath())
|
||||
if rendered.Error() != want {
|
||||
t.Errorf("rendered = %q, want %q", rendered.Error(), want)
|
||||
}
|
||||
|
||||
other := errors.New("connect: permission denied")
|
||||
if got := userIPCError(other); got != other {
|
||||
t.Errorf("unrelated error rewritten to %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,56 @@ The returned object `p` provides two methods:
|
||||
|--------|-------------|
|
||||
| `p:on(event, callback)` | Subscribe to a playback event |
|
||||
| `p:config(key)` | Read a config value from `[plugins.myplugin]` in config.toml |
|
||||
| `p:publish(topic, payload, options)` | Publish a namespaced event to local IPC subscribers |
|
||||
|
||||
## Plugin event pub/sub
|
||||
|
||||
Plugins can publish JSON-compatible values to external programs connected to
|
||||
Cliamp's owner-only IPC socket. Topics are automatically isolated beneath the
|
||||
installed plugin name; a plugin installed as `myplugin.lua` publishing
|
||||
`"playback"` produces the topic `plugin.myplugin.playback`. The name contributes
|
||||
exactly one topic segment: any character outside letters, digits, `_`, and `-`
|
||||
becomes `_`, so `my.plugin.lua` publishes under the prefix `plugin.my_plugin.*`
|
||||
(publishing `"playback"` from it gives `plugin.my_plugin.playback`) and can never
|
||||
collide with topics from a plugin named `my`. Because that folding is lossy,
|
||||
namespaces are unique per session: if `my.plugin.lua` and `my_plugin.lua` are
|
||||
both installed, the first one loaded (plugins load in name order) owns the
|
||||
`plugin.my_plugin.*` prefix and `p:publish()` in the other returns `nil, err`
|
||||
naming the owner. Rename one of them to publish from both. Subscribers must name
|
||||
complete topics, not prefixes.
|
||||
|
||||
```lua
|
||||
p:publish("playback", {
|
||||
status = cliamp.player.state(),
|
||||
title = cliamp.track.title(),
|
||||
}, { retain = true })
|
||||
```
|
||||
|
||||
With `retain = true`, Cliamp keeps the latest value in memory and sends it to
|
||||
new subscribers immediately. Retained values are discarded when Cliamp exits;
|
||||
no event data is persisted to disk. Publishing is non-blocking. A subscriber
|
||||
that cannot keep up is disconnected rather than blocking the player.
|
||||
|
||||
Subscribe by opening `cliamp.sock` and sending one NDJSON request:
|
||||
|
||||
```json
|
||||
{"cmd":"subscribe","topics":["plugin.myplugin.playback"]}
|
||||
```
|
||||
|
||||
After `{"ok":true}`, the connection becomes a server-to-client event stream:
|
||||
|
||||
```json
|
||||
{"event":"plugin.myplugin.playback","seq":42,"time":1786685741,"retained":true,"data":{"status":"playing","title":"Track"}}
|
||||
```
|
||||
|
||||
Subscriptions use exact topic matches, accept at most 32 topics, and are
|
||||
streaming-only; use another IPC connection for ordinary commands. Sending any
|
||||
further bytes on a subscription closes it. Payloads are limited to 64 KiB and
|
||||
are converted with the same nesting and cycle rules as
|
||||
[`cliamp.json`](#cliampjson). Topic segments may contain letters, digits, `.`,
|
||||
`_`, and `-`. `p:publish()` requires no permission because IPC remains local to
|
||||
the same user and the existing `status` command already exposes playback
|
||||
metadata.
|
||||
|
||||
## Events
|
||||
|
||||
@@ -315,6 +365,10 @@ local tbl = cliamp.json.decode('{"key": "value"}')
|
||||
local str = cliamp.json.encode({ key = "value" })
|
||||
```
|
||||
|
||||
Tables are encoded up to 64 levels deep; anything deeper, and any cyclic
|
||||
reference, becomes `null` instead of failing. The same conversion is used by
|
||||
`p:publish()` and `cliamp.store`.
|
||||
|
||||
### cliamp.store
|
||||
|
||||
A persistent per-plugin key/value store. Values (strings, numbers, booleans,
|
||||
|
||||
@@ -116,6 +116,21 @@ Response format:
|
||||
{"ok": false, "error": "cliamp is not running"}
|
||||
```
|
||||
|
||||
## Plugin Event Subscriptions
|
||||
|
||||
Lua plugins can publish retained or transient events over the same IPC socket.
|
||||
A subscriber sends one request:
|
||||
|
||||
```json
|
||||
{"cmd":"subscribe","topics":["plugin.myplugin.playback"]}
|
||||
```
|
||||
|
||||
After the `{"ok":true}` acknowledgment, that connection is a streaming-only
|
||||
NDJSON event feed; sending anything else on it closes the subscription.
|
||||
Retained events are replayed immediately and exist only in Cliamp memory. See
|
||||
[Lua Plugins](plugins.md#plugin-event-pubsub) for the `p:publish()` API and
|
||||
event envelope.
|
||||
|
||||
## Socket Details
|
||||
|
||||
- **Path**: `~/.config/cliamp/cliamp.sock` (or `%APPDATA%\cliamp\cliamp.sock` on Windows when `HOME` is unset; created on TUI start, removed on shutdown)
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ func SendWithDeadline(sockPath string, req Request, deadline time.Duration) (Res
|
||||
conn, err := dialSocket(sockPath, 3*time.Second)
|
||||
if err != nil {
|
||||
if isSocketUnavailable(err) {
|
||||
return Response{}, fmt.Errorf("cliamp is not running (no socket at %s)", sockPath)
|
||||
return Response{}, fmt.Errorf("no socket at %s: %w", sockPath, ErrNotRunning)
|
||||
}
|
||||
return Response{}, fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
|
||||
+24
-2
@@ -1,6 +1,9 @@
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -58,8 +61,27 @@ func TestSendNoServer(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("Send to missing socket should error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not running") {
|
||||
t.Errorf("error = %q, want to mention 'not running'", err.Error())
|
||||
if !errors.Is(err, ErrNotRunning) {
|
||||
t.Errorf("error = %v, want ErrNotRunning", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), sock) {
|
||||
t.Errorf("error = %q, want the socket path", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Every entry point reports a missing server through the same sentinel so
|
||||
// callers can branch without matching message text.
|
||||
func TestEntryPointsReportErrNotRunning(t *testing.T) {
|
||||
sock := filepath.Join(shortTempDir(t), "missing.sock")
|
||||
|
||||
if _, err := Send(sock, Request{Cmd: "status"}); !errors.Is(err, ErrNotRunning) {
|
||||
t.Errorf("Send error = %v, want ErrNotRunning", err)
|
||||
}
|
||||
if _, err := Subscribe(sock, []string{"plugin.test.playback"}); !errors.Is(err, ErrNotRunning) {
|
||||
t.Errorf("Subscribe error = %v, want ErrNotRunning", err)
|
||||
}
|
||||
if err := StreamBands(context.Background(), sock, time.Millisecond, io.Discard); !errors.Is(err, ErrNotRunning) {
|
||||
t.Errorf("StreamBands error = %v, want ErrNotRunning", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,11 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNotRunning reports that nothing is listening on the IPC socket. It carries
|
||||
// no CLI wording on purpose: command-layer callers match it with errors.Is and
|
||||
// render the user-facing message themselves.
|
||||
var ErrNotRunning = errors.New("no listener on socket")
|
||||
|
||||
func dialSocket(sockPath string, timeout time.Duration) (net.Conn, error) {
|
||||
return net.DialTimeout("unix", sockPath, timeout)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ type Request struct {
|
||||
Limit int `json:"limit,omitempty"`
|
||||
NewName string `json:"new_name,omitempty"`
|
||||
Track *TrackInfo `json:"track,omitempty"`
|
||||
Topics []string `json:"topics,omitempty"`
|
||||
}
|
||||
|
||||
// Response is the JSON response sent by the server.
|
||||
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxTopicsPerSubscription = 32
|
||||
maxRetainedTopics = 256
|
||||
maxEventPayloadSize = 64 << 10
|
||||
subscriberBufferSize = 32
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidTopic = errors.New("invalid event topic")
|
||||
ErrTooManyTopics = errors.New("too many subscription topics")
|
||||
ErrPayloadTooLarge = errors.New("event payload exceeds 64 KiB")
|
||||
ErrTooManyRetained = errors.New("too many retained event topics")
|
||||
)
|
||||
|
||||
// Event is one message delivered over an IPC subscription. Sequence numbers
|
||||
// are process-local and monotonically increasing. Retained marks events replayed
|
||||
// to a newly connected subscriber.
|
||||
type Event struct {
|
||||
Event string `json:"event"`
|
||||
Sequence uint64 `json:"seq"`
|
||||
Time int64 `json:"time"`
|
||||
Retained bool `json:"retained,omitempty"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// Subscription is an in-memory event stream owned by a Broker.
|
||||
type Subscription struct {
|
||||
broker *Broker
|
||||
id uint64
|
||||
events <-chan Event
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (s *Subscription) Events() <-chan Event { return s.events }
|
||||
|
||||
// Close unregisters the subscription. It is safe to call more than once.
|
||||
func (s *Subscription) Close() {
|
||||
if s == nil || s.broker == nil {
|
||||
return
|
||||
}
|
||||
s.once.Do(func() { s.broker.unsubscribe(s.id) })
|
||||
}
|
||||
|
||||
type subscriber struct {
|
||||
topics map[string]struct{}
|
||||
events chan Event
|
||||
}
|
||||
|
||||
// Broker distributes process-local events and optionally retains the latest
|
||||
// event per topic. Publish never waits for a subscriber: a slow subscriber is
|
||||
// disconnected rather than being allowed to block Cliamp or a Lua callback.
|
||||
type Broker struct {
|
||||
mu sync.Mutex
|
||||
nextEvent uint64
|
||||
nextSub uint64
|
||||
retained map[string]Event
|
||||
subscribers map[uint64]*subscriber
|
||||
closed bool
|
||||
}
|
||||
|
||||
func NewBroker() *Broker {
|
||||
return &Broker{
|
||||
retained: make(map[string]Event),
|
||||
subscribers: make(map[uint64]*subscriber),
|
||||
}
|
||||
}
|
||||
|
||||
// Publish sends data to current subscribers and, when retain is true, stores
|
||||
// the latest value in memory for replay to future subscribers.
|
||||
func (b *Broker) Publish(topic string, data json.RawMessage, retain bool) error {
|
||||
if !validTopic(topic) {
|
||||
return ErrInvalidTopic
|
||||
}
|
||||
if len(data) > maxEventPayloadSize {
|
||||
return ErrPayloadTooLarge
|
||||
}
|
||||
if !json.Valid(data) {
|
||||
return errors.New("event payload is not valid JSON")
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.closed {
|
||||
return errors.New("event broker is closed")
|
||||
}
|
||||
// Reject before consuming a sequence number so numbering stays gap-free.
|
||||
if retain {
|
||||
if _, exists := b.retained[topic]; !exists && len(b.retained) >= maxRetainedTopics {
|
||||
return ErrTooManyRetained
|
||||
}
|
||||
}
|
||||
b.nextEvent++
|
||||
event := Event{
|
||||
Event: topic,
|
||||
Sequence: b.nextEvent,
|
||||
Time: time.Now().Unix(),
|
||||
Data: append(json.RawMessage(nil), data...),
|
||||
}
|
||||
if retain {
|
||||
b.retained[topic] = event
|
||||
}
|
||||
for id, sub := range b.subscribers {
|
||||
if _, ok := sub.topics[topic]; !ok {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case sub.events <- event:
|
||||
default:
|
||||
delete(b.subscribers, id)
|
||||
close(sub.events)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Subscribe registers an exact-topic subscription and queues retained values
|
||||
// before any subsequently published events. Topic ordering makes retained
|
||||
// replay deterministic.
|
||||
func (b *Broker) Subscribe(topics []string) (*Subscription, error) {
|
||||
if len(topics) == 0 {
|
||||
return nil, errors.New("at least one topic is required")
|
||||
}
|
||||
if len(topics) > maxTopicsPerSubscription {
|
||||
return nil, ErrTooManyTopics
|
||||
}
|
||||
set := make(map[string]struct{}, len(topics))
|
||||
for _, topic := range topics {
|
||||
if !validTopic(topic) {
|
||||
return nil, ErrInvalidTopic
|
||||
}
|
||||
set[topic] = struct{}{}
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.closed {
|
||||
return nil, errors.New("event broker is closed")
|
||||
}
|
||||
b.nextSub++
|
||||
id := b.nextSub
|
||||
capacity := subscriberBufferSize
|
||||
if len(set) > capacity {
|
||||
capacity = len(set)
|
||||
}
|
||||
ch := make(chan Event, capacity)
|
||||
sub := &subscriber{topics: set, events: ch}
|
||||
b.subscribers[id] = sub
|
||||
|
||||
sorted := make([]string, 0, len(set))
|
||||
for topic := range set {
|
||||
sorted = append(sorted, topic)
|
||||
}
|
||||
sort.Strings(sorted)
|
||||
for _, topic := range sorted {
|
||||
if event, ok := b.retained[topic]; ok {
|
||||
event.Retained = true
|
||||
ch <- event
|
||||
}
|
||||
}
|
||||
return &Subscription{broker: b, id: id, events: ch}, nil
|
||||
}
|
||||
|
||||
// ClearPrefix removes retained values under a plugin namespace. Live
|
||||
// subscribers remain connected and will receive future publications.
|
||||
func (b *Broker) ClearPrefix(prefix string) {
|
||||
b.mu.Lock()
|
||||
for topic := range b.retained {
|
||||
if strings.HasPrefix(topic, prefix) {
|
||||
delete(b.retained, topic)
|
||||
}
|
||||
}
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
// Close disconnects all subscribers and rejects future publications and
|
||||
// subscriptions. It is safe to call more than once.
|
||||
func (b *Broker) Close() {
|
||||
b.mu.Lock()
|
||||
if !b.closed {
|
||||
b.closed = true
|
||||
for id, sub := range b.subscribers {
|
||||
delete(b.subscribers, id)
|
||||
close(sub.events)
|
||||
}
|
||||
clear(b.retained)
|
||||
}
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *Broker) unsubscribe(id uint64) {
|
||||
b.mu.Lock()
|
||||
if sub, ok := b.subscribers[id]; ok {
|
||||
delete(b.subscribers, id)
|
||||
close(sub.events)
|
||||
}
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func validTopic(topic string) bool {
|
||||
if topic == "" || len(topic) > 256 || strings.HasPrefix(topic, ".") || strings.HasSuffix(topic, ".") || strings.Contains(topic, "..") {
|
||||
return false
|
||||
}
|
||||
for _, r := range topic {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBrokerRetainsAndReplaysLatestEvent(t *testing.T) {
|
||||
broker := NewBroker()
|
||||
if err := broker.Publish("plugin.discord-rpc.playback", json.RawMessage(`{"status":"playing"}`), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := broker.Publish("plugin.discord-rpc.playback", json.RawMessage(`{"status":"paused"}`), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sub, err := broker.Subscribe([]string{"plugin.discord-rpc.playback"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer sub.Close()
|
||||
|
||||
select {
|
||||
case event := <-sub.Events():
|
||||
if !event.Retained || string(event.Data) != `{"status":"paused"}` || event.Sequence != 2 {
|
||||
t.Fatalf("retained event = %#v", event)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for retained event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokerFiltersTopics(t *testing.T) {
|
||||
broker := NewBroker()
|
||||
sub, err := broker.Subscribe([]string{"plugin.one.playback"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer sub.Close()
|
||||
|
||||
if err := broker.Publish("plugin.two.playback", json.RawMessage(`{}`), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case event := <-sub.Events():
|
||||
t.Fatalf("received unrelated event: %#v", event)
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
|
||||
if err := broker.Publish("plugin.one.playback", json.RawMessage(`{"ok":true}`), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case event := <-sub.Events():
|
||||
if event.Event != "plugin.one.playback" || event.Retained {
|
||||
t.Fatalf("event = %#v", event)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokerRejectsInvalidInput(t *testing.T) {
|
||||
broker := NewBroker()
|
||||
for _, topic := range []string{"", ".bad", "bad.", "bad..topic", "bad/topic", "bad topic"} {
|
||||
if err := broker.Publish(topic, json.RawMessage(`{}`), false); !errors.Is(err, ErrInvalidTopic) {
|
||||
t.Errorf("Publish(%q) error = %v, want ErrInvalidTopic", topic, err)
|
||||
}
|
||||
}
|
||||
if err := broker.Publish("valid.topic", json.RawMessage(`not-json`), false); err == nil {
|
||||
t.Fatal("expected invalid JSON error")
|
||||
}
|
||||
if err := broker.Publish("valid.topic", make(json.RawMessage, maxEventPayloadSize+1), false); !errors.Is(err, ErrPayloadTooLarge) {
|
||||
t.Fatalf("oversized payload error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokerClearsRetainedNamespace(t *testing.T) {
|
||||
broker := NewBroker()
|
||||
_ = broker.Publish("plugin.one.playback", json.RawMessage(`{}`), true)
|
||||
_ = broker.Publish("plugin.two.playback", json.RawMessage(`{}`), true)
|
||||
broker.ClearPrefix("plugin.one.")
|
||||
|
||||
one, err := broker.Subscribe([]string{"plugin.one.playback"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer one.Close()
|
||||
select {
|
||||
case event := <-one.Events():
|
||||
t.Fatalf("cleared event replayed: %#v", event)
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
|
||||
two, err := broker.Subscribe([]string{"plugin.two.playback"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer two.Close()
|
||||
select {
|
||||
case <-two.Events():
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("unrelated retained event was removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokerCloseDisconnectsAndRejectsWork(t *testing.T) {
|
||||
broker := NewBroker()
|
||||
sub, err := broker.Subscribe([]string{"plugin.test.events"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
broker.Close()
|
||||
if _, ok := <-sub.Events(); ok {
|
||||
t.Fatal("subscription remained open")
|
||||
}
|
||||
if err := broker.Publish("plugin.test.events", json.RawMessage(`{}`), false); err == nil {
|
||||
t.Fatal("publish succeeded after close")
|
||||
}
|
||||
if _, err := broker.Subscribe([]string{"plugin.test.events"}); err == nil {
|
||||
t.Fatal("subscribe succeeded after close")
|
||||
}
|
||||
broker.Close()
|
||||
sub.Close()
|
||||
}
|
||||
|
||||
func TestBrokerDisconnectsSlowSubscriber(t *testing.T) {
|
||||
broker := NewBroker()
|
||||
sub, err := broker.Subscribe([]string{"plugin.test.events"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer sub.Close()
|
||||
|
||||
for i := 0; i < subscriberBufferSize+1; i++ {
|
||||
if err := broker.Publish("plugin.test.events", json.RawMessage(`{}`), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for range sub.Events() {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokerRejectedRetainDoesNotConsumeSequence(t *testing.T) {
|
||||
broker := NewBroker()
|
||||
for i := range maxRetainedTopics {
|
||||
topic := fmt.Sprintf("plugin.filler.topic%d", i)
|
||||
if err := broker.Publish(topic, json.RawMessage(`{}`), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := broker.Publish("plugin.filler.overflow", json.RawMessage(`{}`), true); !errors.Is(err, ErrTooManyRetained) {
|
||||
t.Fatalf("retained overflow error = %v, want ErrTooManyRetained", err)
|
||||
}
|
||||
|
||||
sub, err := broker.Subscribe([]string{"plugin.seq.check"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer sub.Close()
|
||||
if err := broker.Publish("plugin.seq.check", json.RawMessage(`{}`), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case event := <-sub.Events():
|
||||
if event.Sequence != uint64(maxRetainedTopics)+1 {
|
||||
t.Fatalf("sequence = %d, want %d", event.Sequence, maxRetainedTopics+1)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for event")
|
||||
}
|
||||
}
|
||||
+75
-5
@@ -23,12 +23,15 @@ type Dispatcher interface {
|
||||
Send(msg any)
|
||||
}
|
||||
|
||||
const ipcRequestReadTimeout = 60 * time.Second
|
||||
|
||||
// Server listens on a Unix socket and dispatches IPC commands.
|
||||
type Server struct {
|
||||
listener net.Listener
|
||||
sockPath string
|
||||
disp Dispatcher
|
||||
plugins PluginDispatcher
|
||||
broker *Broker
|
||||
done chan struct{}
|
||||
wg sync.WaitGroup
|
||||
|
||||
@@ -72,9 +75,17 @@ func (s *Server) SetPluginDispatcher(p PluginDispatcher) {
|
||||
s.plugins = p
|
||||
}
|
||||
|
||||
// NewServer creates and starts the IPC server. It cleans up stale sockets
|
||||
// before binding. The socket is created with 0600 permissions (owner only).
|
||||
// NewServer creates and starts the IPC server with a new event broker.
|
||||
func NewServer(sockPath string, disp Dispatcher) (*Server, error) {
|
||||
return NewServerWithBroker(sockPath, disp, nil)
|
||||
}
|
||||
|
||||
// NewServerWithBroker creates and starts the IPC server using broker. A new
|
||||
// broker is allocated when broker is nil.
|
||||
func NewServerWithBroker(sockPath string, disp Dispatcher, broker *Broker) (*Server, error) {
|
||||
if broker == nil {
|
||||
broker = NewBroker()
|
||||
}
|
||||
if err := cleanStaleSocket(sockPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -108,6 +119,7 @@ func NewServer(sockPath string, disp Dispatcher) (*Server, error) {
|
||||
listener: ln,
|
||||
sockPath: sockPath,
|
||||
disp: disp,
|
||||
broker: broker,
|
||||
done: make(chan struct{}),
|
||||
conns: make(map[net.Conn]struct{}),
|
||||
}
|
||||
@@ -173,7 +185,7 @@ func (s *Server) handleConn(conn net.Conn) {
|
||||
// Per-request deadline so long-lived streaming clients (e.g. vis bands
|
||||
// polling) aren't killed at a fixed wall clock, but idle clients still
|
||||
// time out.
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
conn.SetReadDeadline(time.Now().Add(ipcRequestReadTimeout))
|
||||
if !scanner.Scan() {
|
||||
return
|
||||
}
|
||||
@@ -188,12 +200,64 @@ func (s *Server) handleConn(conn net.Conn) {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.EqualFold(req.Cmd, "subscribe") {
|
||||
s.streamSubscription(conn, req.Topics)
|
||||
return
|
||||
}
|
||||
|
||||
resp := s.dispatch(req)
|
||||
conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
||||
writeResponse(conn, resp)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) streamSubscription(conn net.Conn, topics []string) {
|
||||
// handleConn sets a per-request read deadline. Subscriptions are idle,
|
||||
// server-to-client streams after the initial request, so they must not
|
||||
// inherit that deadline or they will be closed every request timeout.
|
||||
if err := conn.SetReadDeadline(time.Time{}); err != nil {
|
||||
writeResponse(conn, Response{OK: false, Error: "clear subscription deadline: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
sub, err := s.broker.Subscribe(topics)
|
||||
if err != nil {
|
||||
writeResponse(conn, Response{OK: false, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
defer sub.Close()
|
||||
conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
||||
if !writeJSONLine(conn, Response{OK: true}) {
|
||||
return
|
||||
}
|
||||
|
||||
// A subscription is server-to-client after its acknowledgment. Keep a read
|
||||
// pending solely to detect client disconnects even when no events publish.
|
||||
peerClosed := make(chan struct{})
|
||||
go func() {
|
||||
var one [1]byte
|
||||
_, _ = conn.Read(one[:])
|
||||
close(peerClosed)
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.done:
|
||||
return
|
||||
case <-peerClosed:
|
||||
return
|
||||
case event, ok := <-sub.Events():
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
||||
if !writeJSONLine(conn, event) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dispatch handles a single parsed request.
|
||||
func (s *Server) dispatch(req Request) Response {
|
||||
switch strings.ToLower(req.Cmd) {
|
||||
@@ -392,14 +456,20 @@ func (s *Server) handleStatus() Response {
|
||||
|
||||
// writeResponse marshals a Response as JSON and writes it followed by a newline.
|
||||
func writeResponse(conn net.Conn, resp Response) {
|
||||
data, err := json.Marshal(resp)
|
||||
_ = writeJSONLine(conn, resp)
|
||||
}
|
||||
|
||||
func writeJSONLine(conn net.Conn, value any) bool {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return
|
||||
return false
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if _, err := conn.Write(data); err != nil {
|
||||
applog.Warn("ipc: write response: %v", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// cleanStaleSocket removes a leftover socket and PID file from a dead process.
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ func StreamBands(ctx context.Context, sockPath string, interval time.Duration, o
|
||||
conn, err := dialSocket(sockPath, 3*time.Second)
|
||||
if err != nil {
|
||||
if isSocketUnavailable(err) {
|
||||
return fmt.Errorf("cliamp is not running (no socket at %s)", sockPath)
|
||||
return fmt.Errorf("no socket at %s: %w", sockPath, ErrNotRunning)
|
||||
}
|
||||
return fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EventStream is a client-side subscription to IPC events.
|
||||
type EventStream struct {
|
||||
conn net.Conn
|
||||
scanner *bufio.Scanner
|
||||
}
|
||||
|
||||
// Subscribe opens a streaming-only IPC connection for exact topic names.
|
||||
// The caller must close the stream when it is no longer needed.
|
||||
func Subscribe(sockPath string, topics []string) (*EventStream, error) {
|
||||
conn, err := dialSocket(sockPath, 3*time.Second)
|
||||
if err != nil {
|
||||
if isSocketUnavailable(err) {
|
||||
return nil, fmt.Errorf("no socket at %s: %w", sockPath, ErrNotRunning)
|
||||
}
|
||||
return nil, fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
fail := func(err error) (*EventStream, error) {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
request, err := json.Marshal(Request{Cmd: "subscribe", Topics: topics})
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("marshal subscribe request: %w", err))
|
||||
}
|
||||
if _, err := conn.Write(append(request, '\n')); err != nil {
|
||||
return fail(fmt.Errorf("write subscribe request: %w", err))
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(conn)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
if !scanner.Scan() {
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fail(fmt.Errorf("read subscribe response: %w", err))
|
||||
}
|
||||
return fail(fmt.Errorf("no subscribe response from server"))
|
||||
}
|
||||
var response Response
|
||||
if err := json.Unmarshal(scanner.Bytes(), &response); err != nil {
|
||||
return fail(fmt.Errorf("decode subscribe response: %w", err))
|
||||
}
|
||||
if !response.OK {
|
||||
return fail(fmt.Errorf("subscribe: %s", response.Error))
|
||||
}
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
return &EventStream{conn: conn, scanner: scanner}, nil
|
||||
}
|
||||
|
||||
// Next blocks until the next event arrives. A returned error means the stream
|
||||
// ended and should be closed and reconnected.
|
||||
func (s *EventStream) Next() (Event, error) {
|
||||
if s == nil || s.conn == nil {
|
||||
return Event{}, fmt.Errorf("subscription is closed")
|
||||
}
|
||||
if !s.scanner.Scan() {
|
||||
if err := s.scanner.Err(); err != nil {
|
||||
return Event{}, fmt.Errorf("read event: %w", err)
|
||||
}
|
||||
return Event{}, fmt.Errorf("event stream closed")
|
||||
}
|
||||
var event Event
|
||||
if err := json.Unmarshal(s.scanner.Bytes(), &event); err != nil {
|
||||
return Event{}, fmt.Errorf("decode event: %w", err)
|
||||
}
|
||||
return event, nil
|
||||
}
|
||||
|
||||
func (s *EventStream) Close() error {
|
||||
if s == nil || s.conn == nil {
|
||||
return nil
|
||||
}
|
||||
return s.conn.Close()
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSubscribeStreamsRetainedAndLiveEvents(t *testing.T) {
|
||||
broker := NewBroker()
|
||||
if err := broker.Publish("plugin.test.playback", json.RawMessage(`{"state":"stopped"}`), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sock := filepath.Join(shortTempDir(t), "cliamp.sock")
|
||||
server, err := NewServerWithBroker(sock, &captureDispatcher{}, broker)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = server.Close() })
|
||||
|
||||
stream, err := Subscribe(sock, []string{"plugin.test.playback"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = stream.Close() })
|
||||
|
||||
retained, err := stream.Next()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retained.Retained || string(retained.Data) != `{"state":"stopped"}` {
|
||||
t.Fatalf("retained event = %#v", retained)
|
||||
}
|
||||
|
||||
if err := broker.Publish("plugin.test.playback", json.RawMessage(`{"state":"playing"}`), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
live, err := stream.Next()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if live.Retained || string(live.Data) != `{"state":"playing"}` || live.Sequence <= retained.Sequence {
|
||||
t.Fatalf("live event = %#v after %#v", live, retained)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribeRejectsMissingTopics(t *testing.T) {
|
||||
sock := filepath.Join(shortTempDir(t), "cliamp.sock")
|
||||
server, err := NewServer(sock, &captureDispatcher{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = server.Close() })
|
||||
|
||||
if _, err := Subscribe(sock, nil); err == nil {
|
||||
t.Fatal("expected subscription error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionClearsRequestReadDeadline(t *testing.T) {
|
||||
serverConn, clientConn := net.Pipe()
|
||||
broker := NewBroker()
|
||||
server := &Server{broker: broker, done: make(chan struct{})}
|
||||
if err := serverConn.SetReadDeadline(time.Now().Add(25 * time.Millisecond)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
returned := make(chan struct{})
|
||||
go func() {
|
||||
defer close(returned)
|
||||
defer serverConn.Close()
|
||||
server.streamSubscription(serverConn, []string{"plugin.test.playback"})
|
||||
}()
|
||||
defer clientConn.Close()
|
||||
|
||||
scanner := bufio.NewScanner(clientConn)
|
||||
if !scanner.Scan() {
|
||||
t.Fatalf("read acknowledgment: %v", scanner.Err())
|
||||
}
|
||||
var acknowledgment Response
|
||||
if err := json.Unmarshal(scanner.Bytes(), &acknowledgment); err != nil || !acknowledgment.OK {
|
||||
t.Fatalf("acknowledgment = %q, err=%v", scanner.Bytes(), err)
|
||||
}
|
||||
|
||||
// Wait beyond the inherited request deadline, then prove the subscription
|
||||
// is still alive by delivering a newly published event.
|
||||
time.Sleep(75 * time.Millisecond)
|
||||
if err := broker.Publish("plugin.test.playback", json.RawMessage(`{"state":"playing"}`), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := clientConn.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !scanner.Scan() {
|
||||
t.Fatalf("read event after request deadline: %v", scanner.Err())
|
||||
}
|
||||
var event Event
|
||||
if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event.Event != "plugin.test.playback" {
|
||||
t.Fatalf("event = %#v", event)
|
||||
}
|
||||
|
||||
_ = clientConn.Close()
|
||||
select {
|
||||
case <-returned:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("subscription did not stop after client close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionEndsWhenServerCloses(t *testing.T) {
|
||||
sock := filepath.Join(shortTempDir(t), "cliamp.sock")
|
||||
server, err := NewServer(sock, &captureDispatcher{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stream, err := Subscribe(sock, []string{"plugin.test.playback"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
if err := server.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := stream.Next()
|
||||
done <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatal("expected closed stream error")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("stream did not close with server")
|
||||
}
|
||||
}
|
||||
+28
-2
@@ -67,7 +67,19 @@ func jsonToLua(L *lua.LState, v any) lua.LValue {
|
||||
}
|
||||
}
|
||||
|
||||
// maxLuaConvertDepth bounds table nesting accepted by luaToGo. Lua can build a
|
||||
// deeply nested table cheaply, and unbounded recursion here would overflow the
|
||||
// Go stack — a fatal error the plugin sandbox cannot contain.
|
||||
const maxLuaConvertDepth = 64
|
||||
|
||||
func luaToGo(val lua.LValue) any {
|
||||
return luaToGoValue(val, nil, 0)
|
||||
}
|
||||
|
||||
// luaToGoValue converts val to its Go equivalent. Tables already being
|
||||
// converted on the current path (cycles) and tables nested deeper than
|
||||
// maxLuaConvertDepth become nil instead of recursing forever.
|
||||
func luaToGoValue(val lua.LValue, path map[*lua.LTable]struct{}, depth int) any {
|
||||
switch v := val.(type) {
|
||||
case *lua.LNilType:
|
||||
return nil
|
||||
@@ -78,19 +90,33 @@ func luaToGo(val lua.LValue) any {
|
||||
case lua.LString:
|
||||
return string(v)
|
||||
case *lua.LTable:
|
||||
if depth >= maxLuaConvertDepth {
|
||||
return nil
|
||||
}
|
||||
if _, cyclic := path[v]; cyclic {
|
||||
return nil
|
||||
}
|
||||
if path == nil {
|
||||
path = make(map[*lua.LTable]struct{})
|
||||
}
|
||||
// Tracking only the active path keeps repeated sibling references
|
||||
// (a DAG) convertible while still catching true cycles.
|
||||
path[v] = struct{}{}
|
||||
defer delete(path, v)
|
||||
|
||||
// Detect if it's an array (sequential integer keys starting at 1).
|
||||
maxN := v.MaxN()
|
||||
if maxN > 0 {
|
||||
arr := make([]any, 0, maxN)
|
||||
for i := 1; i <= maxN; i++ {
|
||||
arr = append(arr, luaToGo(v.RawGetInt(i)))
|
||||
arr = append(arr, luaToGoValue(v.RawGetInt(i), path, depth+1))
|
||||
}
|
||||
return arr
|
||||
}
|
||||
m := make(map[string]any)
|
||||
v.ForEach(func(key, value lua.LValue) {
|
||||
if ks, ok := key.(lua.LString); ok {
|
||||
m[string(ks)] = luaToGo(value)
|
||||
m[string(ks)] = luaToGoValue(value, path, depth+1)
|
||||
}
|
||||
})
|
||||
return m
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package luaplugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
@@ -113,3 +114,107 @@ func TestLuaToGoRoundtrip(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaToGoBreaksTableCycles(t *testing.T) {
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
|
||||
self := L.NewTable()
|
||||
self.RawSetString("name", lua.LString("self"))
|
||||
self.RawSetString("self", self)
|
||||
|
||||
left := L.NewTable()
|
||||
right := L.NewTable()
|
||||
left.RawSetString("other", right)
|
||||
right.RawSetString("other", left)
|
||||
self.RawSetString("left", left)
|
||||
|
||||
// A cycle must not recurse until the Go stack overflows, which would kill
|
||||
// cliamp instead of failing inside the plugin sandbox.
|
||||
got, ok := luaToGo(self).(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("luaToGo returned %T, want map", luaToGo(self))
|
||||
}
|
||||
if got["name"] != "self" {
|
||||
t.Fatalf("name = %v, want self", got["name"])
|
||||
}
|
||||
if got["self"] != nil {
|
||||
t.Fatalf("cyclic self reference = %v, want nil", got["self"])
|
||||
}
|
||||
if _, err := json.Marshal(got); err != nil {
|
||||
t.Fatalf("marshal cyclic table: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaToGoKeepsRepeatedReferences(t *testing.T) {
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
|
||||
shared := L.NewTable()
|
||||
shared.RawSetString("id", lua.LString("shared"))
|
||||
root := L.NewTable()
|
||||
root.RawSetString("first", shared)
|
||||
root.RawSetString("second", shared)
|
||||
|
||||
// The same table reached twice on different paths is not a cycle.
|
||||
got, ok := luaToGo(root).(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("luaToGo returned %T, want map", luaToGo(root))
|
||||
}
|
||||
for _, key := range []string{"first", "second"} {
|
||||
child, ok := got[key].(map[string]any)
|
||||
if !ok || child["id"] != "shared" {
|
||||
t.Fatalf("%s = %#v, want shared table", key, got[key])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaToGoLimitsNestingDepth(t *testing.T) {
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
|
||||
root := L.NewTable()
|
||||
deepest := root
|
||||
for range maxLuaConvertDepth + 10 {
|
||||
next := L.NewTable()
|
||||
deepest.RawSetString("next", next)
|
||||
deepest = next
|
||||
}
|
||||
|
||||
current, ok := luaToGo(root).(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("luaToGo returned %T, want map", luaToGo(root))
|
||||
}
|
||||
depth := 1
|
||||
for {
|
||||
next, isTable := current["next"].(map[string]any)
|
||||
if !isTable {
|
||||
break
|
||||
}
|
||||
current = next
|
||||
depth++
|
||||
}
|
||||
if depth != maxLuaConvertDepth {
|
||||
t.Fatalf("converted depth = %d, want %d", depth, maxLuaConvertDepth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONEncodeSurvivesCyclicTable(t *testing.T) {
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
cliamp := L.NewTable()
|
||||
registerJSONAPI(L, cliamp)
|
||||
L.SetGlobal("cliamp", cliamp)
|
||||
|
||||
if err := L.DoString(`
|
||||
local t = {name = "loop"}
|
||||
t.self = t
|
||||
_G.result = cliamp.json.encode(t)
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The cyclic reference becomes null instead of crashing the process.
|
||||
if got := L.GetGlobal("result").String(); got != `{"name":"loop","self":null}` {
|
||||
t.Fatalf("encode(cyclic) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
package luaplugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
type capturedEvent struct {
|
||||
topic string
|
||||
data json.RawMessage
|
||||
retain bool
|
||||
}
|
||||
|
||||
// capturePublisher records calls in order and is safe for concurrent use, since
|
||||
// async hooks publish from their own goroutines.
|
||||
type capturePublisher struct {
|
||||
mu sync.Mutex
|
||||
events []capturedEvent
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (p *capturePublisher) Publish(topic string, data json.RawMessage, retain bool) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.events = append(p.events, capturedEvent{topic: topic, data: append(json.RawMessage(nil), data...), retain: retain})
|
||||
p.calls = append(p.calls, "publish "+topic)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *capturePublisher) ClearPrefix(prefix string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.calls = append(p.calls, "clear "+prefix)
|
||||
}
|
||||
|
||||
func (p *capturePublisher) captured() ([]capturedEvent, []string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.events, p.calls
|
||||
}
|
||||
|
||||
func TestPluginPublishUsesInstalledNamespaceAndRetention(t *testing.T) {
|
||||
manager := newTestManager()
|
||||
publisher := &capturePublisher{}
|
||||
manager.SetEventPublisher(publisher)
|
||||
|
||||
loadTestPlugin(t, manager, "installed-name", `
|
||||
local p = plugin.register({name = "spoofed-name", type = "hook"})
|
||||
local ok, err = p:publish("playback", {status = "playing", position = 12}, {retain = true})
|
||||
if not ok then error(err) end
|
||||
`)
|
||||
|
||||
events, _ := publisher.captured()
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("published events = %d, want 1", len(events))
|
||||
}
|
||||
event := events[0]
|
||||
if event.topic != "plugin.installed-name.playback" || !event.retain {
|
||||
t.Fatalf("event = %#v", event)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(event.data, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["status"] != "playing" || payload["position"] != float64(12) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginPublishReportsUnavailablePublisher(t *testing.T) {
|
||||
manager := newTestManager()
|
||||
plugin := loadTestPlugin(t, manager, "test", `
|
||||
local p = plugin.register({name = "test", type = "hook"})
|
||||
_G.ok, _G.err = p:publish("playback", {})
|
||||
`)
|
||||
if plugin.L.GetGlobal("ok") != lua.LNil {
|
||||
t.Fatal("publish unexpectedly succeeded")
|
||||
}
|
||||
if plugin.L.GetGlobal("err").String() != "plugin event publisher is unavailable" {
|
||||
t.Fatalf("error = %q", plugin.L.GetGlobal("err").String())
|
||||
}
|
||||
}
|
||||
|
||||
// A dotted install name must not be able to produce the same topic as another
|
||||
// plugin publishing a dotted topic.
|
||||
func TestPluginPublishNamespaceAvoidsDottedNameCollision(t *testing.T) {
|
||||
manager := newTestManager()
|
||||
publisher := &capturePublisher{}
|
||||
manager.SetEventPublisher(publisher)
|
||||
|
||||
loadTestPlugin(t, manager, "foo.bar", `
|
||||
local p = plugin.register({name = "foo.bar", type = "hook"})
|
||||
p:publish("playback", {})
|
||||
`)
|
||||
loadTestPlugin(t, manager, "foo", `
|
||||
local p = plugin.register({name = "foo", type = "hook"})
|
||||
p:publish("bar.playback", {})
|
||||
`)
|
||||
|
||||
events, _ := publisher.captured()
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("published events = %d, want 2", len(events))
|
||||
}
|
||||
if events[0].topic != "plugin.foo_bar.playback" {
|
||||
t.Errorf("dotted plugin topic = %q, want plugin.foo_bar.playback", events[0].topic)
|
||||
}
|
||||
if events[1].topic != "plugin.foo.bar.playback" {
|
||||
t.Errorf("dotted topic = %q, want plugin.foo.bar.playback", events[1].topic)
|
||||
}
|
||||
if events[0].topic == events[1].topic {
|
||||
t.Fatalf("namespaces collided on %q", events[0].topic)
|
||||
}
|
||||
}
|
||||
|
||||
// Folding "." to "_" is lossy, so two names that fold alike must not end up
|
||||
// sharing a namespace: the first loaded keeps it, the second cannot publish.
|
||||
func TestPluginPublishRejectsFoldedNamespaceClash(t *testing.T) {
|
||||
manager := newTestManager()
|
||||
publisher := &capturePublisher{}
|
||||
manager.SetEventPublisher(publisher)
|
||||
|
||||
loadTestPlugin(t, manager, "my.plugin", `
|
||||
local p = plugin.register({name = "my.plugin", type = "hook"})
|
||||
local ok, err = p:publish("playback", {})
|
||||
if not ok then error(err) end
|
||||
`)
|
||||
late := loadTestPlugin(t, manager, "my_plugin", `
|
||||
local p = plugin.register({name = "my_plugin", type = "hook"})
|
||||
_G.ok, _G.err = p:publish("playback", {})
|
||||
`)
|
||||
|
||||
if late.L.GetGlobal("ok") != lua.LNil {
|
||||
t.Fatal("second plugin published into a namespace it does not own")
|
||||
}
|
||||
if got := late.L.GetGlobal("err").String(); !strings.Contains(got, `already used by plugin "my.plugin"`) {
|
||||
t.Errorf("error = %q, want the owning plugin named", got)
|
||||
}
|
||||
events, _ := publisher.captured()
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("published events = %d, want 1", len(events))
|
||||
}
|
||||
if events[0].topic != "plugin.my_plugin.playback" {
|
||||
t.Errorf("topic = %q, want plugin.my_plugin.playback", events[0].topic)
|
||||
}
|
||||
}
|
||||
|
||||
// A file that never registers must release its namespace for the next plugin,
|
||||
// including when plugin.register() renamed it before the chunk failed.
|
||||
func TestNamespaceReleasedWhenPluginFailsToLoad(t *testing.T) {
|
||||
manager := newTestManager()
|
||||
publisher := &capturePublisher{}
|
||||
manager.SetEventPublisher(publisher)
|
||||
|
||||
loadTestPluginExpectError(t, manager, "my.plugin", `error("boom")`)
|
||||
loadTestPlugin(t, manager, "my_plugin", `
|
||||
local p = plugin.register({name = "my_plugin", type = "hook"})
|
||||
local ok, err = p:publish("playback", {})
|
||||
if not ok then error(err) end
|
||||
`)
|
||||
|
||||
events, _ := publisher.captured()
|
||||
if len(events) != 1 || events[0].topic != "plugin.my_plugin.playback" {
|
||||
t.Fatalf("events = %#v, want one plugin.my_plugin.playback", events)
|
||||
}
|
||||
}
|
||||
|
||||
// plugin.register() renames Plugin.Name, so namespace release must key off the
|
||||
// installed filename instead.
|
||||
func TestNamespaceReleasedAfterRegisterRenamesPlugin(t *testing.T) {
|
||||
manager := newTestManager()
|
||||
publisher := &capturePublisher{}
|
||||
manager.SetEventPublisher(publisher)
|
||||
|
||||
loadTestPluginExpectError(t, manager, "my.plugin", `
|
||||
plugin.register({name = "renamed-after-claim", type = "hook"})
|
||||
error("boom")
|
||||
`)
|
||||
if owner, taken := manager.namespaces["my_plugin"]; taken {
|
||||
t.Fatalf("namespace still claimed by %q after failed load", owner)
|
||||
}
|
||||
|
||||
late := loadTestPlugin(t, manager, "my_plugin", `
|
||||
local p = plugin.register({name = "my_plugin", type = "hook"})
|
||||
_G.ok, _G.err = p:publish("playback", {})
|
||||
`)
|
||||
if late.L.GetGlobal("ok") == lua.LNil {
|
||||
t.Fatalf("publish failed: %s", late.L.GetGlobal("err").String())
|
||||
}
|
||||
events, _ := publisher.captured()
|
||||
if len(events) != 1 || events[0].topic != "plugin.my_plugin.playback" {
|
||||
t.Fatalf("events = %#v, want one plugin.my_plugin.playback", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerCloseClearsRetainedAfterPublishersStop(t *testing.T) {
|
||||
manager := newTestManager()
|
||||
publisher := &capturePublisher{}
|
||||
manager.SetEventPublisher(publisher)
|
||||
loadTestPlugin(t, manager, "late", `plugin.register({name = "late", type = "hook"})`)
|
||||
|
||||
// Stand in for an async hook goroutine that is still running when Close
|
||||
// starts and publishes a retained event just before it finishes. Close must
|
||||
// wait for it, then clear the namespace — not the other way around.
|
||||
manager.wg.Add(1)
|
||||
go func() {
|
||||
defer manager.wg.Done()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
_ = publisher.Publish("plugin.late.playback", json.RawMessage(`{}`), true)
|
||||
}()
|
||||
|
||||
manager.Close()
|
||||
|
||||
_, calls := publisher.captured()
|
||||
want := []string{"publish plugin.late.playback", "clear plugin.late."}
|
||||
if len(calls) != len(want) {
|
||||
t.Fatalf("calls = %v, want %v", calls, want)
|
||||
}
|
||||
for i, call := range calls {
|
||||
if call != want[i] {
|
||||
t.Fatalf("calls = %v, want %v", calls, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+135
-12
@@ -4,6 +4,7 @@
|
||||
package luaplugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -20,14 +21,17 @@ import (
|
||||
|
||||
// Plugin represents a single loaded Lua plugin.
|
||||
type Plugin struct {
|
||||
Name string
|
||||
Version string
|
||||
Description string
|
||||
Type string // "hook" or "visualizer"
|
||||
L *lua.LState
|
||||
mu sync.Mutex // serializes all LState access (LState is not thread-safe)
|
||||
config map[string]string // per-plugin config from config.toml
|
||||
perms map[string]bool // declared permissions (e.g. "control")
|
||||
Name string
|
||||
Version string
|
||||
Description string
|
||||
Type string // "hook" or "visualizer"
|
||||
L *lua.LState
|
||||
mu sync.Mutex // serializes all LState access (LState is not thread-safe)
|
||||
config map[string]string // per-plugin config from config.toml
|
||||
perms map[string]bool // declared permissions (e.g. "control")
|
||||
namespaceOwner string // installed filename; plugin.register() cannot change it
|
||||
namespace string // namespaceOwner reduced to one event topic segment
|
||||
namespaceErr error // set when another plugin claimed that namespace first
|
||||
}
|
||||
|
||||
// StateProvider supplies read-only access to player/playlist state.
|
||||
@@ -96,6 +100,12 @@ type UIProvider struct {
|
||||
ShowMessage func(text string, duration time.Duration) // injected via prog.Send
|
||||
}
|
||||
|
||||
// EventPublisher accepts namespaced JSON events emitted by Lua plugins.
|
||||
type EventPublisher interface {
|
||||
Publish(topic string, data json.RawMessage, retain bool) error
|
||||
ClearPrefix(prefix string)
|
||||
}
|
||||
|
||||
// Manager owns all loaded plugins and dispatches events to them.
|
||||
type Manager struct {
|
||||
plugins []*Plugin
|
||||
@@ -106,9 +116,11 @@ type Manager struct {
|
||||
commands map[string]map[string]*luaHook // plugin name -> command name -> handler
|
||||
visPlugs []*luaVis // Lua visualizers in registration order
|
||||
visMap map[string]*luaVis // name -> Lua visualizer
|
||||
namespaces map[string]string // event namespace -> owning plugin name
|
||||
state StateProvider
|
||||
control ControlProvider
|
||||
ui UIProvider
|
||||
publisher EventPublisher
|
||||
timers *timerManager
|
||||
execs *execManager
|
||||
logger *pluginLogger
|
||||
@@ -119,16 +131,20 @@ type Manager struct {
|
||||
|
||||
// New scans the plugin directory and loads all .lua files.
|
||||
// pluginCfg maps plugin names to their [plugins.<name>] config keys.
|
||||
// publisher backs p:publish() and may be nil; it is installed before any plugin
|
||||
// runs so a plugin can publish from its top-level chunk.
|
||||
// Returns a Manager (possibly with 0 plugins) and any non-fatal load error.
|
||||
func New(pluginCfg map[string]map[string]string) (*Manager, error) {
|
||||
func New(pluginCfg map[string]map[string]string, publisher EventPublisher) (*Manager, error) {
|
||||
m := &Manager{
|
||||
hooks: make(map[string][]*luaHook),
|
||||
keyBinds: make(map[string][]*luaHook),
|
||||
keyBindDescs: make(map[string]KeyBinding),
|
||||
commands: make(map[string]map[string]*luaHook),
|
||||
visMap: make(map[string]*luaVis),
|
||||
namespaces: make(map[string]string),
|
||||
timers: newTimerManager(),
|
||||
execs: newExecManager(resolveAllowedBinaries(pluginCfg)),
|
||||
publisher: publisher,
|
||||
}
|
||||
|
||||
dir, err := appdir.PluginDir()
|
||||
@@ -230,10 +246,13 @@ func (m *Manager) loadPlugin(path, name string, cfg map[string]string) (*Plugin,
|
||||
sandbox(L)
|
||||
|
||||
p := &Plugin{
|
||||
Name: name,
|
||||
L: L,
|
||||
config: cfg,
|
||||
Name: name,
|
||||
namespaceOwner: name,
|
||||
namespace: eventNamespace(name),
|
||||
L: L,
|
||||
config: cfg,
|
||||
}
|
||||
m.claimNamespace(p)
|
||||
|
||||
// Register the plugin.register() global.
|
||||
m.registerPluginAPI(L, p)
|
||||
@@ -264,6 +283,13 @@ func (m *Manager) loadPlugin(path, name string, cfg map[string]string) (*Plugin,
|
||||
|
||||
func (m *Manager) cleanupPlugin(p *Plugin) {
|
||||
m.mu.Lock()
|
||||
// Release the event namespace only if this plugin owns it. Compare against
|
||||
// namespaceOwner, not Name: plugin.register() can rename Name after the
|
||||
// claim, and a renamed plugin that then fails to load must not keep the
|
||||
// namespace locked away from a later colliding plugin.
|
||||
if owner, ok := m.namespaces[p.namespace]; ok && owner == p.namespaceOwner {
|
||||
delete(m.namespaces, p.namespace)
|
||||
}
|
||||
for event, hooks := range m.hooks {
|
||||
m.hooks[event] = filterOutPlugin(hooks, p)
|
||||
}
|
||||
@@ -373,6 +399,38 @@ func (m *Manager) registerPluginAPI(L *lua.LState, p *Plugin) {
|
||||
return 1
|
||||
}))
|
||||
|
||||
// p:publish(topic, payload, {retain=true}) publishes only inside the
|
||||
// immutable namespace derived from the installed plugin filename.
|
||||
L.SetField(obj, "publish", L.NewFunction(func(L *lua.LState) int {
|
||||
topic := L.CheckString(2)
|
||||
payload := luaToGo(L.Get(3))
|
||||
retain := false
|
||||
if options, ok := L.Get(4).(*lua.LTable); ok {
|
||||
retain = lua.LVAsBool(options.RawGetString("retain"))
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err == nil {
|
||||
m.mu.RLock()
|
||||
publisher := m.publisher
|
||||
m.mu.RUnlock()
|
||||
if publisher == nil {
|
||||
err = fmt.Errorf("plugin event publisher is unavailable")
|
||||
} else if p.namespaceErr != nil {
|
||||
err = p.namespaceErr
|
||||
} else {
|
||||
fullTopic := "plugin." + p.namespace + "." + topic
|
||||
err = publisher.Publish(fullTopic, data, retain)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
L.Push(lua.LNil)
|
||||
L.Push(lua.LString(err.Error()))
|
||||
return 2
|
||||
}
|
||||
L.Push(lua.LTrue)
|
||||
return 1
|
||||
}))
|
||||
|
||||
m.registerKeymapAPI(L, obj, p)
|
||||
m.registerCommandAPI(L, obj, p)
|
||||
|
||||
@@ -443,6 +501,58 @@ func resolveAllowedBinaries(pluginCfg map[string]map[string]string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// eventNamespace reduces an installed plugin name to a single event topic
|
||||
// segment. Without this, a dot in the name makes topics ambiguous: a plugin
|
||||
// installed as "foo.bar" publishing "playback" and one installed as "foo"
|
||||
// publishing "bar.playback" would both produce plugin.foo.bar.playback.
|
||||
// Characters that IPC topics reject are folded the same way so every installed
|
||||
// plugin can publish, whatever its filename.
|
||||
//
|
||||
// Folding is lossy: "foo.bar" and "foo_bar" both yield "foo_bar". loadPlugin
|
||||
// therefore lets only the first plugin claim a namespace and disables
|
||||
// publishing for later ones, so two plugins can never share a topic.
|
||||
func eventNamespace(name string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(name))
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune('_')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// claimNamespace records p as the owner of its event namespace, or marks p as
|
||||
// unable to publish when another plugin already owns that namespace. Load order
|
||||
// is sorted by installed name, so the winner is deterministic. Ownership is
|
||||
// tracked by installed filename because plugin.register() can rename p.Name.
|
||||
func (m *Manager) claimNamespace(p *Plugin) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.namespaces == nil {
|
||||
m.namespaces = make(map[string]string)
|
||||
}
|
||||
if owner, taken := m.namespaces[p.namespace]; taken && owner != p.namespaceOwner {
|
||||
p.namespaceErr = fmt.Errorf("event namespace %q is already used by plugin %q; rename this plugin to publish events", p.namespace, owner)
|
||||
if m.logger != nil {
|
||||
m.logger.log(p.namespaceOwner, "warn", "%v", p.namespaceErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
m.namespaces[p.namespace] = p.namespaceOwner
|
||||
}
|
||||
|
||||
// SetEventPublisher replaces the publisher backing p:publish(). New installs
|
||||
// the publisher before plugins run; this is for callers that wire it later.
|
||||
func (m *Manager) SetEventPublisher(publisher EventPublisher) {
|
||||
m.mu.Lock()
|
||||
m.publisher = publisher
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetStateProvider sets the function pointers used by the Lua API to
|
||||
// query live player/playlist state.
|
||||
func (m *Manager) SetStateProvider(sp StateProvider) {
|
||||
@@ -473,6 +583,19 @@ func (m *Manager) Close() {
|
||||
// Wait for any in-flight async hook goroutines to finish before closing
|
||||
// the LStates they call into.
|
||||
m.wg.Wait()
|
||||
// Drop retained events only once every publisher has stopped, so a late
|
||||
// async handler cannot leave a retained value behind.
|
||||
m.mu.RLock()
|
||||
publisher := m.publisher
|
||||
m.mu.RUnlock()
|
||||
if publisher != nil {
|
||||
for _, p := range m.plugins {
|
||||
if p.namespaceErr != nil {
|
||||
continue // never owned the namespace, so nothing of its own is retained
|
||||
}
|
||||
publisher.ClearPrefix("plugin." + p.namespace + ".")
|
||||
}
|
||||
}
|
||||
if m.logger != nil {
|
||||
m.logger.close()
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestBundledPluginsLoad(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
mgr, err := New(nil)
|
||||
mgr, err := New(nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("bundled plugins failed to load (API compatibility broken): %v", err)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -303,13 +304,16 @@ func run(overrides config.Overrides, positional []string, daemon bool) error {
|
||||
|
||||
themes := theme.LoadAll()
|
||||
|
||||
luaMgr, luaErr := luaplugin.New(cfg.Plugins)
|
||||
pluginBroker := ipc.NewBroker()
|
||||
defer pluginBroker.Close()
|
||||
|
||||
luaMgr, luaErr := luaplugin.New(cfg.Plugins, pluginBroker)
|
||||
if luaErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "lua plugins: %v\n", luaErr)
|
||||
}
|
||||
if luaMgr != nil {
|
||||
defer luaMgr.Close()
|
||||
luaMgr.SetReservedKeys(model.ReservedKeys())
|
||||
defer luaMgr.Close()
|
||||
}
|
||||
|
||||
m := model.New(p, pl, providers, defaultProvider, localProv, themes, luaMgr, config.SaveFunc{})
|
||||
@@ -465,7 +469,7 @@ func run(overrides config.Overrides, positional []string, daemon bool) error {
|
||||
})
|
||||
}
|
||||
|
||||
ipcSrv, ipcErr := ipc.NewServer(ipc.DefaultSocketPath(), ipc.DispatcherFunc(func(msg any) { prog.Send(msg) }))
|
||||
ipcSrv, ipcErr := ipc.NewServerWithBroker(ipc.DefaultSocketPath(), ipc.DispatcherFunc(func(msg any) { prog.Send(msg) }), pluginBroker)
|
||||
if ipcErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "ipc: %v\n", ipcErr)
|
||||
} else {
|
||||
@@ -525,10 +529,19 @@ func wireMediaCtl(prog *tea.Program) (*mediactl.Service, error) {
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// userIPCError renders ipc.ErrNotRunning as the wording users see. The ipc
|
||||
// package returns a bare sentinel, so all CLI copy stays in the command layer.
|
||||
func userIPCError(err error) error {
|
||||
if errors.Is(err, ipc.ErrNotRunning) {
|
||||
return fmt.Errorf("cliamp is not running (no socket at %s)", ipc.DefaultSocketPath())
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func ipcSend(req ipc.Request) (ipc.Response, error) {
|
||||
resp, err := ipc.Send(ipc.DefaultSocketPath(), req)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
return resp, userIPCError(err)
|
||||
}
|
||||
if !resp.OK {
|
||||
return resp, fmt.Errorf("%s", resp.Error)
|
||||
@@ -541,7 +554,7 @@ func ipcSend(req ipc.Request) (ipc.Response, error) {
|
||||
func ipcSendLong(req ipc.Request, deadline time.Duration) (ipc.Response, error) {
|
||||
resp, err := ipc.SendWithDeadline(ipc.DefaultSocketPath(), req, deadline)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
return resp, userIPCError(err)
|
||||
}
|
||||
if !resp.OK {
|
||||
return resp, fmt.Errorf("%s", resp.Error)
|
||||
|
||||
+2
-2
@@ -769,8 +769,8 @@ user_id = "your-account-user-id"</code></pre>
|
||||
<div class="feature"><div class="feature-icon">≡</div><div class="feature-name">Diagnostic Logging</div><p>File logs at <span class="path">~/.config/cliamp/cliamp.log</span>. Set <code>log_level</code> in config or <code>--log-level</code> on the CLI: <code>debug</code>, <code>info</code>, <code>warn</code>, <code>error</code>.</p></div>
|
||||
<div class="feature"><div class="feature-icon">↑</div><div class="feature-name">Self-Update</div><p>Run <code>--upgrade</code> to update to the latest release in-terminal.</p></div>
|
||||
<div class="feature"><div class="feature-icon">$</div><div class="feature-name">Env-Interpolated Secrets</div><p>Reference any string in <code>config.toml</code> from the environment with <code>${VAR}</code> or <code>$VAR</code>. Keep passwords and tokens out of the file.</p></div>
|
||||
<div class="feature"><div class="feature-icon">⌁</div><div class="feature-name">Remote Control</div><p>Control a running instance from another terminal via local-socket IPC. Run with <code>--daemon</code> for headless playback driven entirely by scripts or Waybar.</p></div>
|
||||
<div class="feature"><div class="feature-icon">⎔</div><div class="feature-name">Lua Plugins</div><p>Lua 5.1 sandboxed plugin system. Hook events, add visualizers, push data.</p></div>
|
||||
<div class="feature"><div class="feature-icon">⌁</div><div class="feature-name">Remote Control</div><p>Control a running instance from another terminal via local-socket IPC. Run with <code>--daemon</code> for headless playback driven entirely by scripts or Waybar. Subscribe to plugin events on the same socket for a push NDJSON stream instead of polling.</p></div>
|
||||
<div class="feature"><div class="feature-icon">⎔</div><div class="feature-name">Lua Plugins</div><p>Lua 5.1 sandboxed plugin system. Hook events, add visualizers, push data. Publish namespaced events with <code>p:publish()</code>; retained events replay to new subscribers, so local companion apps get current state the moment they connect. Values convert to JSON up to 64 levels deep, and cyclic references become <code>null</code>.</p></div>
|
||||
<div class="feature"><div class="feature-icon">▣</div><div class="feature-name">Save to Disk</div><p>Press <kbd>Ctrl+S</kbd> to save the current track to <code>~/Music</code>.</p></div>
|
||||
<div class="feature"><div class="feature-icon">↔</div><div class="feature-name">Responsive TUI</div><p>Full, compact, and minimal layouts keep playback usable in terminal splits and small SSH sessions.</p></div>
|
||||
<div class="feature"><div class="feature-icon">⟲</div><div class="feature-name">Undo Queue Edits</div><p>Restore the last playlist removal or queue clear with <kbd>Ctrl+Z</kbd>.</p></div>
|
||||
|
||||
Reference in New Issue
Block a user