Files

261 lines
6.4 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package remoteinput
import (
"context"
"errors"
"io"
"sync"
"time"
)
const Watchdog = 3 * time.Second
const ackQueueSize = 32
const finalAckTimeout = 250 * time.Millisecond
var (
ErrExpired = errors.New("remote input helper heartbeat expired")
ErrTarget = errors.New("remote input helper target failed")
ErrOutput = errors.New("remote input helper acknowledgements unavailable")
ErrCleanup = errors.New("remote input helper cleanup failed")
errEOF = errors.New("remote input helper owner closed")
)
// Target methods run on Run's caller, never on its I/O goroutines. Close must
// retire keys before destroying native state. A blocked native method cannot
// be interrupted here: watchdog recovery requires a responsive native server.
type Target interface {
Apply(name string, pressed bool) error
Close() error
}
type ackWrite struct {
ack Ack
done chan error
}
// Run owns valid supplied streams and target through cleanup and I/O join.
// Stream Close must interrupt blocked reads/writes; AdoptPipe supplies that
// property for inherited Go file descriptors. The caller supplies one fixed
// nonzero epoch and must not launch a replacement until cleanup is confirmed.
func Run(ctx context.Context, epoch uint64, control, lifetime io.ReadCloser, output io.WriteCloser, target Target) error {
return run(ctx, epoch, control, lifetime, output, target, Watchdog)
}
func run(ctx context.Context, epoch uint64, control, lifetime io.ReadCloser, output io.WriteCloser, target Target, timeout time.Duration) error {
if ctx == nil || epoch == 0 || control == nil || lifetime == nil || output == nil || target == nil || timeout <= 0 {
return ErrProtocol
}
ownerContext, cancelOwner := context.WithCancelCause(ctx)
defer cancelOwner(context.Canceled)
ioContext, stopIO := context.WithCancel(context.Background())
results := make(chan Operation, 1)
acks := make(chan ackWrite, ackQueueSize)
writeFailure := make(chan struct{}, 1)
var workers sync.WaitGroup
workers.Add(3)
go func() {
defer workers.Done()
// This pipe never carries data. Its EOF cannot sit behind queued keys.
var value [1]byte
n, err := lifetime.Read(value[:])
if n == 0 && err == io.EOF {
cancelOwner(errEOF)
} else {
cancelOwner(ErrProtocol)
}
}()
go func() {
defer workers.Done()
for {
frame := make([]byte, OperationSize)
_, err := io.ReadFull(control, frame)
var operation Operation
if err == nil {
operation, err = DecodeOperation(frame)
}
if err != nil {
if err == io.EOF {
cancelOwner(errEOF)
} else {
cancelOwner(ErrProtocol)
}
return
}
select {
case results <- operation:
case <-ioContext.Done():
return
}
}
}()
go func() {
defer workers.Done()
for {
select {
case <-ioContext.Done():
return
case item := <-acks:
if ioContext.Err() != nil {
return
}
frame, err := EncodeAck(item.ack)
if err == nil {
err = writeAll(output, frame)
}
if item.done != nil {
item.done <- err
}
if err != nil {
cancelOwner(ErrOutput)
writeFailure <- struct{}{}
return
}
}
}
}()
defer func() {
stopIO()
_ = control.Close()
_ = lifetime.Close()
_ = output.Close()
workers.Wait()
}()
enqueue := func(ack Ack, done chan error) bool {
select {
case acks <- ackWrite{ack: ack, done: done}:
return true
default:
return false
}
}
deadline := time.Now().Add(timeout)
timer := time.NewTimer(timeout)
defer timer.Stop()
var sequence uint64
var cause error
reason := ReasonEOF
ownerStopped := func() {
cause = context.Cause(ownerContext)
switch cause {
case errEOF:
cause, reason = nil, ReasonEOF
case ErrProtocol:
reason = ReasonProtocol
case ErrOutput:
reason = ReasonOutput
default:
cause = ctx.Err()
if cause == nil {
cause = context.Canceled
}
reason = ReasonCancelled
}
}
enqueue(Ack{Kind: Ready, Epoch: epoch}, nil)
loop:
for {
select {
case <-ownerContext.Done():
ownerStopped()
break loop
case <-writeFailure:
cause, reason = ErrOutput, ReasonOutput
break loop
case <-timer.C:
if time.Now().Before(deadline) {
timer.Reset(time.Until(deadline))
continue
}
cause, reason = ErrExpired, ReasonExpired
break loop
case operation := <-results:
// Check before every frame, including a heartbeat. Busy input or a
// late heartbeat cannot win a select race against expired authority.
if ownerContext.Err() != nil {
ownerStopped()
break loop
}
if !time.Now().Before(deadline) {
cause, reason = ErrExpired, ReasonExpired
break loop
}
if operation.Epoch != epoch || operation.Sequence <= sequence {
cause, reason = ErrProtocol, ReasonProtocol
break loop
}
sequence = operation.Sequence
switch operation.Kind {
case Close:
reason = ReasonClose
break loop
case Heartbeat:
deadline = time.Now().Add(timeout)
timer.Reset(timeout)
case Down, Up:
if err := target.Apply(operation.Name, operation.Kind == Down); err != nil {
cause, reason = ErrTarget, ReasonTarget
break loop
}
}
if ownerContext.Err() != nil {
ownerStopped()
break loop
}
if !time.Now().Before(deadline) {
cause, reason = ErrExpired, ReasonExpired
break loop
}
if !enqueue(Ack{Kind: Accepted, Epoch: epoch, Sequence: sequence}, nil) {
cause, reason = ErrOutput, ReasonOutput
break loop
}
}
}
// No acknowledgement or blocked pipe can postpone native cleanup.
_ = control.Close()
cleanup := target.Close()
done := make(chan error, 1)
if enqueue(Ack{Kind: Closed, Reason: reason, CleanupFailed: cleanup != nil, Epoch: epoch, Sequence: sequence}, done) {
flush := time.NewTimer(finalAckTimeout)
select {
case err := <-done:
if err != nil && cause == nil {
cause = ErrOutput
}
case <-writeFailure:
if cause == nil {
cause = ErrOutput
}
case <-flush.C:
if cause == nil {
cause = ErrOutput
}
}
flush.Stop()
} else if cause == nil {
cause = ErrOutput
}
if cleanup != nil {
return ErrCleanup
}
return cause
}
func writeAll(writer io.Writer, frame []byte) error {
for len(frame) > 0 {
n, err := writer.Write(frame)
if err != nil {
return err
}
if n <= 0 || n > len(frame) {
return io.ErrShortWrite
}
frame = frame[n:]
}
return nil
}