Better notification organization.
This commit is contained in:
1 parent
81cc630319
commit
2c02a79655
14 files changed
+1276
-40
No files matched your search
@@ -0,0 +1,418 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package notifications shares silent history and cue admission between readers.
|
||||
// The first reader hosts an in-memory coordinator on a per-user abstract Unix
|
||||
// socket. No notification text is written to disk and no service manager is used.
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const Limit = 100
|
||||
const SoundInterval = 30 * time.Second
|
||||
const exchangeTimeout = 400 * time.Millisecond
|
||||
|
||||
type Entry struct {
|
||||
ID string
|
||||
Text string
|
||||
Received time.Time
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Entries []Entry
|
||||
LastSound time.Time
|
||||
}
|
||||
|
||||
type request struct {
|
||||
Version int
|
||||
Seed Snapshot
|
||||
Claim bool
|
||||
Member string
|
||||
Leave bool
|
||||
}
|
||||
|
||||
type response struct {
|
||||
Version int
|
||||
State Snapshot
|
||||
Allowed bool
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
UID int
|
||||
id string
|
||||
address string
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
mu sync.Mutex
|
||||
state Snapshot
|
||||
brokerMu sync.Mutex
|
||||
broker *broker
|
||||
wake chan struct{}
|
||||
updates chan struct{}
|
||||
sounds chan func(context.Context)
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// New starts only private IPC work. Namespace isolates native tests from live
|
||||
// readers; production uses the empty namespace and the kernel account ID.
|
||||
func New(ctx context.Context, uid int, namespace string) *Client {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
c := &Client{UID: uid, id: fmt.Sprintf("%x", random_id()), address: fmt.Sprintf("@fenrir-notifications.%d%s", uid, namespace), ctx: ctx, cancel: cancel,
|
||||
wake: make(chan struct{}, 1), updates: make(chan struct{}, 1), sounds: make(chan func(context.Context), 1), done: make(chan struct{})}
|
||||
go c.run()
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Client) Updates() <-chan struct{} { return c.updates }
|
||||
|
||||
func (c *Client) Snapshot() Snapshot {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return Snapshot{Entries: slices.Clone(c.state.Entries), LastSound: c.state.LastSound}
|
||||
}
|
||||
|
||||
func (c *Client) Publish(text string, received time.Time) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
c.adopt(Snapshot{Entries: []Entry{{ID: fmt.Sprintf("%x", random_id()), Text: text, Received: received.UTC()}}})
|
||||
signal(c.wake)
|
||||
}
|
||||
|
||||
func random_id() [16]byte {
|
||||
var id [16]byte
|
||||
// crypto/rand.Read cannot return an error with the Go version we require.
|
||||
_, _ = rand.Read(id[:])
|
||||
return id
|
||||
}
|
||||
|
||||
func signal(channel chan struct{}) {
|
||||
select {
|
||||
case channel <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) adopt(state Snapshot) {
|
||||
c.mu.Lock()
|
||||
before := c.state
|
||||
c.state = merge(c.state, state)
|
||||
changed := !slices.Equal(before.Entries, c.state.Entries)
|
||||
c.mu.Unlock()
|
||||
if changed {
|
||||
signal(c.updates)
|
||||
}
|
||||
}
|
||||
|
||||
// Claim_sound fails closed if coordination is unavailable. Notification speech
|
||||
// never depends on its success, and history synchronization cannot call speech.
|
||||
func (c *Client) Claim_sound(ctx context.Context) bool {
|
||||
if last := c.Snapshot().LastSound; !last.IsZero() && time.Since(last) < SoundInterval {
|
||||
return false
|
||||
}
|
||||
result, err := c.exchange(ctx, true)
|
||||
return err == nil && result.Allowed
|
||||
}
|
||||
|
||||
// Sound is for muted notifications: coalesce pending cue requests without
|
||||
// running network or sound waits on the reader's input loop.
|
||||
func (c *Client) Sound(play func(context.Context)) {
|
||||
select {
|
||||
case c.sounds <- play:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) run() {
|
||||
defer close(c.done)
|
||||
tick := time.NewTicker(200 * time.Millisecond)
|
||||
defer tick.Stop()
|
||||
var previousError string
|
||||
for {
|
||||
_, err := c.exchange(c.ctx, false)
|
||||
if err != nil && c.ctx.Err() == nil && err.Error() != previousError {
|
||||
slog.Warn("notification history synchronization failed", "error", err, "at", time.Now())
|
||||
previousError = err.Error()
|
||||
} else if err == nil {
|
||||
previousError = ""
|
||||
}
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
case <-c.wake:
|
||||
case <-tick.C:
|
||||
case play := <-c.sounds:
|
||||
if c.Claim_sound(c.ctx) {
|
||||
play(c.ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Close() {
|
||||
c.closeOnce.Do(func() {
|
||||
c.cancel()
|
||||
<-c.done
|
||||
// Flush the local outbox even when the reader's parent was canceled.
|
||||
_, _ = c.transact(context.Background(), false, true)
|
||||
c.brokerMu.Lock()
|
||||
if c.broker != nil {
|
||||
c.broker.close()
|
||||
}
|
||||
c.brokerMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) exchange(ctx context.Context, claim bool) (response, error) {
|
||||
return c.transact(ctx, claim, false)
|
||||
}
|
||||
|
||||
func (c *Client) transact(ctx context.Context, claim, leaving bool) (response, error) {
|
||||
var result response
|
||||
ctx, cancel := context.WithTimeout(ctx, exchangeTimeout)
|
||||
defer cancel()
|
||||
if !leaving {
|
||||
stop := context.AfterFunc(c.ctx, cancel)
|
||||
defer stop()
|
||||
}
|
||||
if c.UID < 0 || os.Geteuid() != 0 && os.Geteuid() != c.UID {
|
||||
return result, fmt.Errorf("notification account is not permitted")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
dialer := net.Dialer{}
|
||||
connection, err := dialer.DialContext(ctx, "unix", c.address)
|
||||
if errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.ENOENT) {
|
||||
if !leaving {
|
||||
if err := c.host(); err != nil && !errors.Is(err, syscall.EADDRINUSE) {
|
||||
return result, err
|
||||
}
|
||||
connection, err = dialer.DialContext(ctx, "unix", c.address)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer connection.Close()
|
||||
stopConnection := context.AfterFunc(ctx, func() { connection.Close() })
|
||||
defer stopConnection()
|
||||
uid, err := peer_uid(connection.(*net.UnixConn))
|
||||
if err != nil || uid != c.UID && uid != 0 {
|
||||
return result, fmt.Errorf("notification coordinator belongs to another account")
|
||||
}
|
||||
deadline, _ := ctx.Deadline()
|
||||
_ = connection.SetDeadline(deadline)
|
||||
if err := json.NewEncoder(connection).Encode(request{Version: 1, Seed: c.Snapshot(), Claim: claim, Member: c.id, Leave: leaving}); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(connection, 8<<20)).Decode(&result); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if result.Version != 1 || !valid(result.State) {
|
||||
return response{}, fmt.Errorf("invalid notification snapshot")
|
||||
}
|
||||
c.adopt(result.State)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) host() error {
|
||||
c.brokerMu.Lock()
|
||||
defer c.brokerMu.Unlock()
|
||||
if err := c.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: c.address, Net: "unix"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.broker = &broker{listener: listener, uid: c.UID, state: c.Snapshot(), done: make(chan struct{}), members: make(map[string]acknowledgement), changed: make(chan struct{}, 1)}
|
||||
go c.broker.serve()
|
||||
return nil
|
||||
}
|
||||
|
||||
type acknowledgement struct {
|
||||
state Snapshot
|
||||
seen time.Time
|
||||
}
|
||||
|
||||
type broker struct {
|
||||
listener *net.UnixListener
|
||||
uid int
|
||||
mu sync.Mutex
|
||||
state Snapshot
|
||||
workers sync.WaitGroup
|
||||
done chan struct{}
|
||||
members map[string]acknowledgement
|
||||
changed chan struct{}
|
||||
}
|
||||
|
||||
func (b *broker) serve() {
|
||||
defer close(b.done)
|
||||
// Bound resource use independently of the reader's ordinary remote sockets.
|
||||
clients := make(chan struct{}, 16)
|
||||
for {
|
||||
connection, err := b.listener.AcceptUnix()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case clients <- struct{}{}:
|
||||
b.workers.Go(func() {
|
||||
defer func() { connection.Close(); <-clients }()
|
||||
b.handle(connection)
|
||||
})
|
||||
default:
|
||||
connection.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *broker) handle(connection *net.UnixConn) {
|
||||
uid, err := peer_uid(connection)
|
||||
if err != nil || uid != 0 && uid != b.uid {
|
||||
return
|
||||
}
|
||||
_ = connection.SetDeadline(time.Now().Add(exchangeTimeout))
|
||||
var message request
|
||||
if json.NewDecoder(io.LimitReader(connection, 8<<20)).Decode(&message) != nil || message.Version != 1 || len(message.Member) != 32 || !valid(message.Seed) {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
now := time.Now()
|
||||
for id, member := range b.members {
|
||||
if now.Sub(member.seen) > 5*time.Second {
|
||||
delete(b.members, id)
|
||||
}
|
||||
}
|
||||
// A crashed reader cannot deregister. Bound retained acknowledgement state
|
||||
// as well as concurrent connections; history itself is always capped at 100.
|
||||
if _, exists := b.members[message.Member]; !message.Leave && !exists && len(b.members) >= 128 {
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
b.state = merge(b.state, message.Seed)
|
||||
if message.Leave {
|
||||
delete(b.members, message.Member)
|
||||
} else {
|
||||
b.members[message.Member] = acknowledgement{state: message.Seed, seen: now}
|
||||
}
|
||||
allowed := false
|
||||
if message.Claim && (b.state.LastSound.IsZero() || now.Sub(b.state.LastSound) >= SoundInterval) {
|
||||
b.state.LastSound, allowed = now, true
|
||||
}
|
||||
result := response{Version: 1, State: b.state, Allowed: allowed}
|
||||
b.mu.Unlock()
|
||||
signal(b.changed)
|
||||
_ = json.NewEncoder(connection).Encode(result)
|
||||
}
|
||||
|
||||
func (b *broker) close() {
|
||||
// Give surviving replicas time to acknowledge the final snapshot before
|
||||
// releasing the coordinator's socket. This runs after releasing keyboards.
|
||||
deadline := time.NewTimer(3 * exchangeTimeout)
|
||||
defer deadline.Stop()
|
||||
replicate:
|
||||
for {
|
||||
b.mu.Lock()
|
||||
replicated := true
|
||||
for _, member := range b.members {
|
||||
state := member.state
|
||||
merged := merge(state, b.state)
|
||||
if !slices.Equal(merged.Entries, state.Entries) || merged.LastSound.After(state.LastSound) {
|
||||
replicated = false
|
||||
break
|
||||
}
|
||||
}
|
||||
b.mu.Unlock()
|
||||
if replicated {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-b.changed:
|
||||
case <-deadline.C:
|
||||
break replicate
|
||||
}
|
||||
}
|
||||
b.listener.Close()
|
||||
<-b.done
|
||||
b.workers.Wait()
|
||||
}
|
||||
|
||||
func peer_uid(connection *net.UnixConn) (int, error) {
|
||||
raw, err := connection.SyscallConn()
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
var credentials *syscall.Ucred
|
||||
var credentialError error
|
||||
err = raw.Control(func(fd uintptr) {
|
||||
credentials, credentialError = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED)
|
||||
})
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
if credentialError != nil {
|
||||
return -1, credentialError
|
||||
}
|
||||
return int(credentials.Uid), nil
|
||||
}
|
||||
|
||||
func valid(state Snapshot) bool {
|
||||
if len(state.Entries) > Limit || state.LastSound.After(time.Now().Add(time.Second)) {
|
||||
return false
|
||||
}
|
||||
seen := make(map[string]bool, len(state.Entries))
|
||||
for _, entry := range state.Entries {
|
||||
if len(entry.ID) != 32 || seen[entry.ID] || strings.TrimSpace(entry.Text) == "" || len(entry.Text) > 8129 || !utf8.ValidString(entry.Text) || entry.Received.IsZero() {
|
||||
return false
|
||||
}
|
||||
seen[entry.ID] = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func merge(left, right Snapshot) Snapshot {
|
||||
entries := make(map[string]Entry, len(left.Entries)+len(right.Entries))
|
||||
for _, entry := range right.Entries {
|
||||
entries[entry.ID] = entry
|
||||
}
|
||||
for _, entry := range left.Entries {
|
||||
entries[entry.ID] = entry
|
||||
}
|
||||
result := Snapshot{LastSound: left.LastSound}
|
||||
if right.LastSound.After(result.LastSound) {
|
||||
result.LastSound = right.LastSound
|
||||
}
|
||||
for _, entry := range entries {
|
||||
result.Entries = append(result.Entries, entry)
|
||||
}
|
||||
slices.SortFunc(result.Entries, func(a, b Entry) int {
|
||||
if order := a.Received.Compare(b.Received); order != 0 {
|
||||
return order
|
||||
}
|
||||
return strings.Compare(a.ID, b.ID)
|
||||
})
|
||||
if len(result.Entries) > Limit {
|
||||
result.Entries = result.Entries[len(result.Entries)-Limit:]
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in new issue
Block a user