261 lines
6.4 KiB
Go
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
|
|
}
|