iop/apps/agent/internal/localcontrol/client_operations_test.go

778 lines
21 KiB
Go

package localcontrol
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"sync"
"testing"
"time"
"iop/apps/agent/internal/clientprocess"
"iop/packages/go/agentconfig"
"iop/packages/go/agentstate"
iop "iop/proto/gen/iop"
"google.golang.org/protobuf/proto"
)
func TestClientOperationMatrix(t *testing.T) {
controller := newRecordingClientController()
operations := newTestClientOperations(t, controller)
tests := []struct {
operation string
kind string
}{
{operation: OperationClientStart, kind: "flutter"},
{operation: OperationClientStart, kind: "unity"},
{operation: OperationClientStop, kind: "flutter"},
{operation: OperationClientStop, kind: "unity"},
{operation: OperationClientFocus, kind: "flutter"},
}
expectedCalls := make(map[string]int)
for index, test := range tests {
request := clientOperationRequest(
"message-"+test.operation+"-"+test.kind,
"command-"+test.operation+"-"+test.kind,
test.operation,
test.kind,
"",
)
response := operations.Handle(context.Background(), true, request)
if response.GetKind() != iop.AgentLocalKind_AGENT_LOCAL_KIND_RESPONSE ||
!response.GetResponse().GetMutation().GetAccepted() ||
response.GetResponse().GetMutation().GetSubjectId() != test.kind {
t.Fatalf("case %d response = %#v", index, response)
}
expectedCalls[test.operation]++
if calls := controller.callsFor(test.operation); calls != expectedCalls[test.operation] {
t.Fatalf(
"%s calls = %d, want %d",
test.operation,
calls,
expectedCalls[test.operation],
)
}
}
replayRequest := clientOperationRequest(
"message-replay-first",
"command-replay",
OperationClientStart,
"flutter",
"",
)
first := operations.Handle(context.Background(), true, replayRequest)
replayed := cloneEnvelope(replayRequest)
replayed.MessageId = "message-replay-second"
second := operations.Handle(context.Background(), true, replayed)
if !proto.Equal(first, second) {
t.Fatalf("idempotent replay changed response:\nfirst=%v\nsecond=%v", first, second)
}
if calls := controller.callsFor(OperationClientStart); calls != 3 {
t.Fatalf("client.start total calls = %d, want 3", calls)
}
}
func TestUnityDetailStartsOrFocusesFlutter(t *testing.T) {
controller := newRecordingClientController()
operations := newTestClientOperations(t, controller)
first := operations.Handle(
context.Background(),
true,
clientOperationRequest(
"detail-start-message",
"detail-start-command",
OperationClientDetail,
"unity",
"open-run-detail",
),
)
if mutation := first.GetResponse().GetMutation(); mutation.GetSubjectId() != "flutter" ||
mutation.GetSummary() != "start" {
t.Fatalf("first detail response = %#v", first)
}
second := operations.Handle(
context.Background(),
true,
clientOperationRequest(
"detail-focus-message",
"detail-focus-command",
OperationClientDetail,
"unity",
"open-run-detail",
),
)
if mutation := second.GetResponse().GetMutation(); mutation.GetSubjectId() != "flutter" ||
mutation.GetSummary() != "focus" {
t.Fatalf("second detail response = %#v", second)
}
if calls := controller.callsFor(OperationClientDetail); calls != 2 {
t.Fatalf("detail calls = %d, want 2", calls)
}
if calls := controller.callsFor(OperationClientStart); calls != 0 {
t.Fatalf("direct Flutter start port calls = %d, want 0", calls)
}
}
func TestRejectedClientCommandHasZeroProcessCalls(t *testing.T) {
controller := newRecordingClientController()
operations := newTestClientOperations(t, controller)
tests := []struct {
name string
authorized bool
request *iop.AgentLocalEnvelope
code string
}{
{
name: "unauthorized",
authorized: false,
request: clientOperationRequest(
"denied-message",
"denied-command",
OperationClientStart,
"flutter",
"",
),
code: ErrorPermissionDenied,
},
{
name: "unknown kind",
authorized: true,
request: clientOperationRequest(
"unknown-message",
"unknown-command",
OperationClientStart,
"electron",
"",
),
code: ErrorMalformedFrame,
},
{
name: "unity focus bypass",
authorized: true,
request: clientOperationRequest(
"unity-focus-message",
"unity-focus-command",
OperationClientFocus,
"unity",
"",
),
code: ErrorMalformedFrame,
},
{
name: "detail without capability",
authorized: true,
request: clientOperationRequest(
"detail-message",
"detail-command",
OperationClientDetail,
"unity",
"",
),
code: ErrorMalformedFrame,
},
}
for _, test := range tests {
response := operations.Handle(
context.Background(),
test.authorized,
test.request,
)
if response.GetError().GetCode() != test.code {
t.Fatalf(
"%s error = %q, want %q",
test.name,
response.GetError().GetCode(),
test.code,
)
}
}
if calls := controller.totalCalls(); calls != 0 {
t.Fatalf("rejected process calls = %d, want 0", calls)
}
}
func TestClientCommandIDConflictHasZeroAdditionalProcessCalls(t *testing.T) {
controller := newRecordingClientController()
operations := newTestClientOperations(t, controller)
first := clientOperationRequest(
"first-message",
"same-command",
OperationClientStart,
"flutter",
"",
)
if response := operations.Handle(
context.Background(),
true,
first,
); response.GetError() != nil {
t.Fatalf("first response = %#v", response)
}
conflict := clientOperationRequest(
"conflict-message",
"same-command",
OperationClientStart,
"unity",
"",
)
response := operations.Handle(context.Background(), true, conflict)
if response.GetError().GetCode() != ErrorCommandIDConflict {
t.Fatalf("conflict response = %#v", response)
}
if calls := controller.totalCalls(); calls != 1 {
t.Fatalf("conflict process calls = %d, want 1", calls)
}
}
func TestAcceptedIncompleteClientCommandReusesCompletedManagerReceipt(t *testing.T) {
statePath := filepath.Join(t.TempDir(), "state.json")
store, err := agentstate.NewStore(statePath)
if err != nil {
t.Fatalf("NewStore: %v", err)
}
backend := &countingProcessBackend{}
manager, err := clientprocess.NewManager(
context.Background(),
map[string]agentconfig.ClientProcessSpec{
string(clientprocess.ClientFlutter): {
Executable: "/bin/false",
WorkingDirectory: "/tmp",
FocusArgs: []string{"focus"},
},
},
store,
clientprocess.WithProcessBackend(backend),
)
if err != nil {
t.Fatalf("NewManager: %v", err)
}
defer manager.Close(context.Background())
// Start Flutter first so it's live
if _, err := manager.Start(context.Background(), clientprocess.ClientFlutter, "seed-start"); err != nil {
t.Fatalf("Start flutter: %v", err)
}
initialFocusCalls := backend.focusCalls
ledger, err := NewLedger(context.Background(), store, "daemon-ops", 16)
if err != nil {
t.Fatalf("NewLedger: %v", err)
}
operations, err := NewClientOperations(manager, ledger)
if err != nil {
t.Fatalf("NewClientOperations: %v", err)
}
req := clientOperationRequest("msg-1", "cmd-focus-1", OperationClientFocus, "flutter", "")
res1 := operations.Handle(context.Background(), true, req)
if res1.GetError() != nil {
t.Fatalf("first Handle error: %v", res1.GetError())
}
if backend.focusCalls != initialFocusCalls+1 {
t.Fatalf("focus calls = %d, want %d", backend.focusCalls, initialFocusCalls+1)
}
// Simulate ledger restart where command was accepted but not finished in ledger.
// Recreating ledger reads existing store state where Manager stored completed receipt.
ledger2, err := NewLedger(context.Background(), store, "daemon-ops", 16)
if err != nil {
t.Fatalf("NewLedger 2: %v", err)
}
operations2, err := NewClientOperations(manager, ledger2)
if err != nil {
t.Fatalf("NewClientOperations 2: %v", err)
}
reqReplay := clientOperationRequest("msg-2", "cmd-focus-1", OperationClientFocus, "flutter", "")
res2 := operations2.Handle(context.Background(), true, reqReplay)
if res2.GetError() != nil {
t.Fatalf("replay Handle error: %v", res2.GetError())
}
if backend.focusCalls != initialFocusCalls+1 {
t.Fatalf("focus calls after replay = %d, want %d (zero additional calls)", backend.focusCalls, initialFocusCalls+1)
}
}
func TestAcceptedIncompleteClientCommandReusesExactManagerReceiptAfterStateChange(t *testing.T) {
base, err := agentstate.NewStore(filepath.Join(t.TempDir(), "state.json"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
store := &ledgerFinishFailingStore{Store: base, failKey: "local-control/ledger"}
backend := &countingProcessBackend{}
manager, err := clientprocess.NewManager(
context.Background(),
map[string]agentconfig.ClientProcessSpec{
string(clientprocess.ClientFlutter): {
Executable: "/bin/false",
WorkingDirectory: "/tmp",
FocusArgs: []string{"focus"},
},
},
store,
clientprocess.WithProcessBackend(backend),
)
if err != nil {
t.Fatalf("NewManager: %v", err)
}
defer manager.Close(context.Background())
// Live, connected Flutter so focus and a later disconnect are valid.
if _, err := manager.Start(context.Background(), clientprocess.ClientFlutter, "seed-start"); err != nil {
t.Fatalf("Start: %v", err)
}
if _, err := manager.SetConnected(context.Background(), clientprocess.ClientFlutter, true); err != nil {
t.Fatalf("SetConnected true: %v", err)
}
ledger, err := NewLedger(context.Background(), store, "daemon-ops", 16)
if err != nil {
t.Fatalf("NewLedger: %v", err)
}
operations, err := NewClientOperations(manager, ledger)
if err != nil {
t.Fatalf("NewClientOperations: %v", err)
}
// Fail only the first final ledger CAS so the command is durably accepted
// but incomplete while the manager retains the completed focus receipt.
store.armFinishFailure()
first := operations.Handle(
context.Background(),
true,
clientOperationRequest("msg-1", "cmd-focus-x", OperationClientFocus, "flutter", ""),
)
if first.GetError() == nil {
t.Fatalf("expected injected finish failure to surface, got %#v", first)
}
if backend.focusCalls != 1 {
t.Fatalf("focus calls after first handle = %d, want 1", backend.focusCalls)
}
// Intervening state change: disconnect so the current slot projection
// (starting) differs from the immutable focus snapshot (connected).
if _, err := manager.SetConnected(context.Background(), clientprocess.ClientFlutter, false); err != nil {
t.Fatalf("SetConnected false: %v", err)
}
// Recover through a new ledger instance; the accepted-incomplete command
// re-dispatches into manager receipt recovery.
ledger2, err := NewLedger(context.Background(), store, "daemon-ops", 16)
if err != nil {
t.Fatalf("NewLedger 2: %v", err)
}
operations2, err := NewClientOperations(manager, ledger2)
if err != nil {
t.Fatalf("NewClientOperations 2: %v", err)
}
second := operations2.Handle(
context.Background(),
true,
clientOperationRequest("msg-2", "cmd-focus-x", OperationClientFocus, "flutter", ""),
)
if second.GetError() != nil {
t.Fatalf("replay error: %v", second.GetError())
}
if backend.focusCalls != 1 {
t.Fatalf("focus calls after replay = %d, want 1 (zero additional mutation)", backend.focusCalls)
}
mutation := second.GetResponse().GetMutation()
if mutation.GetSubjectId() != "flutter" || mutation.GetSummary() != "focus" {
t.Fatalf("replay mutation = %#v", mutation)
}
// Exact original result: the state reflects the connected snapshot captured
// at completion, not the current disconnected projection.
if mutation.GetState() != string(clientprocess.StateConnected) {
t.Fatalf(
"replay state = %q, want %q (immutable receipt snapshot)",
mutation.GetState(),
clientprocess.StateConnected,
)
}
}
// ledgerFinishFailingStore fails exactly one ledger CompareAndSwap once armed.
// The first ledger CAS after arming is the BeginCommand acceptance; the second
// is the FinishCommand completion, which is the one this store rejects.
type ledgerFinishFailingStore struct {
*agentstate.Store
failKey string
mu sync.Mutex
armed bool
count int
}
func (s *ledgerFinishFailingStore) armFinishFailure() {
s.mu.Lock()
s.armed = true
s.count = 0
s.mu.Unlock()
}
func (s *ledgerFinishFailingStore) CompareAndSwapIntegrationRecord(
ctx context.Context,
key string,
revision string,
payload []byte,
) (string, error) {
s.mu.Lock()
fail := false
if s.armed && key == s.failKey {
s.count++
if s.count == 2 {
fail = true
s.armed = false
}
}
s.mu.Unlock()
if fail {
return "", errors.New("injected finish CAS failure")
}
return s.Store.CompareAndSwapIntegrationRecord(ctx, key, revision, payload)
}
func TestAcceptedIncompleteClientFocusFailsClosedWhenOutcomePending(t *testing.T) {
statePath := filepath.Join(t.TempDir(), "state.json")
store, err := agentstate.NewStore(statePath)
if err != nil {
t.Fatalf("NewStore: %v", err)
}
// Seed store with a live client and a pending focus receipt for cmd-pending
identity := clientprocess.ProcessIdentity{PID: 500, StartToken: "fake-token"}
rec := clientprocess.Record{
SchemaVersion: clientprocess.RecordSchemaVersion,
Kind: clientprocess.ClientFlutter,
State: clientprocess.StateConnected,
Connected: true,
Identity: &identity,
Commands: map[string]clientprocess.CommandReceipt{
"cmd-pending": {
CommandID: "cmd-pending",
Action: "focus",
Status: clientprocess.CommandReceiptPending,
},
},
}
// Write record manually via raw store integration record
payload, err := jsonMarshalRecord(rec)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if _, err := store.CompareAndSwapIntegrationRecord(
context.Background(),
"client-process/flutter",
"",
payload,
); err != nil {
t.Fatalf("seed store: %v", err)
}
backend := &countingProcessBackend{liveIdentity: identity}
manager, err := clientprocess.NewManager(
context.Background(),
map[string]agentconfig.ClientProcessSpec{
string(clientprocess.ClientFlutter): {
Executable: "/bin/false",
WorkingDirectory: "/tmp",
FocusArgs: []string{"focus"},
},
},
store,
clientprocess.WithProcessBackend(backend),
)
if err != nil {
t.Fatalf("NewManager: %v", err)
}
defer manager.Close(context.Background())
ledger, err := NewLedger(context.Background(), store, "daemon-ops", 16)
if err != nil {
t.Fatalf("NewLedger: %v", err)
}
operations, err := NewClientOperations(manager, ledger)
if err != nil {
t.Fatalf("NewClientOperations: %v", err)
}
req := clientOperationRequest("msg-p", "cmd-pending", OperationClientFocus, "flutter", "")
res := operations.Handle(context.Background(), true, req)
if res.GetError() == nil {
t.Fatalf("expected error for pending outcome, got success")
}
if res.GetError().GetCode() != ErrorInvalidState || !res.GetError().GetRetryable() {
t.Fatalf("error = %#v, want retryable ErrorInvalidState", res.GetError())
}
if backend.focusCalls != 0 {
t.Fatalf("backend focus calls = %d, want 0", backend.focusCalls)
}
}
type countingProcessBackend struct {
mu sync.Mutex
focusCalls int
liveIdentity clientprocess.ProcessIdentity
processes map[clientprocess.ProcessIdentity]*countingOwnedProcess
}
func (b *countingProcessBackend) Start(
context.Context,
clientprocess.ClientKind,
agentconfig.ClientProcessSpec,
) (clientprocess.OwnedProcess, error) {
b.mu.Lock()
defer b.mu.Unlock()
if b.processes == nil {
b.processes = make(map[clientprocess.ProcessIdentity]*countingOwnedProcess)
}
id := clientprocess.ProcessIdentity{PID: 300 + len(b.processes), StartToken: "fake"}
proc := &countingOwnedProcess{
identity: id,
done: make(chan struct{}),
}
b.processes[id] = proc
return proc, nil
}
type countingOwnedProcess struct {
identity clientprocess.ProcessIdentity
done chan struct{}
stopOnce sync.Once
}
func (p *countingOwnedProcess) stop() {
p.stopOnce.Do(func() {
close(p.done)
})
}
func (p *countingOwnedProcess) Identity() clientprocess.ProcessIdentity {
return p.identity
}
func (p *countingOwnedProcess) Wait() error {
<-p.done
return nil
}
func (p *countingOwnedProcess) Abort() error {
p.stop()
return nil
}
func (b *countingProcessBackend) Inspect(
_ context.Context,
id clientprocess.ProcessIdentity,
) (clientprocess.IdentityObservation, error) {
b.mu.Lock()
defer b.mu.Unlock()
if proc, ok := b.processes[id]; ok {
select {
case <-proc.done:
return clientprocess.IdentityObservation{State: clientprocess.IdentityExited}, nil
default:
return clientprocess.IdentityObservation{State: clientprocess.IdentityLive, Identity: id}, nil
}
}
return clientprocess.IdentityObservation{
State: clientprocess.IdentityLive,
Identity: id,
}, nil
}
func (b *countingProcessBackend) Signal(
_ context.Context,
id clientprocess.ProcessIdentity,
_ os.Signal,
) error {
b.mu.Lock()
defer b.mu.Unlock()
if proc, ok := b.processes[id]; ok {
proc.stop()
}
return nil
}
func (b *countingProcessBackend) Kill(
_ context.Context,
id clientprocess.ProcessIdentity,
) error {
b.mu.Lock()
defer b.mu.Unlock()
if proc, ok := b.processes[id]; ok {
proc.stop()
}
return nil
}
func (b *countingProcessBackend) Focus(
context.Context,
clientprocess.ClientKind,
agentconfig.ClientProcessSpec,
) error {
b.mu.Lock()
defer b.mu.Unlock()
b.focusCalls++
return nil
}
func jsonMarshalRecord(rec clientprocess.Record) ([]byte, error) {
rec.UpdatedAt = time.Now().UTC()
return json.Marshal(rec)
}
type recordingClientController struct {
mu sync.Mutex
calls map[string]int
flutter bool
}
func newRecordingClientController() *recordingClientController {
return &recordingClientController{calls: make(map[string]int)}
}
func (controller *recordingClientController) Start(
_ context.Context,
kind clientprocess.ClientKind,
_ string,
) (clientprocess.Result, error) {
controller.mu.Lock()
defer controller.mu.Unlock()
controller.calls[OperationClientStart]++
if kind == clientprocess.ClientFlutter {
controller.flutter = true
}
return clientResult(kind, "start"), nil
}
func (controller *recordingClientController) Stop(
_ context.Context,
kind clientprocess.ClientKind,
_ string,
) (clientprocess.Result, error) {
controller.mu.Lock()
defer controller.mu.Unlock()
controller.calls[OperationClientStop]++
if kind == clientprocess.ClientFlutter {
controller.flutter = false
}
result := clientResult(kind, "stop")
result.Record.State = clientprocess.StateStopped
return result, nil
}
func (controller *recordingClientController) Focus(
_ context.Context,
kind clientprocess.ClientKind,
_ string,
) (clientprocess.Result, error) {
controller.mu.Lock()
defer controller.mu.Unlock()
controller.calls[OperationClientFocus]++
return clientResult(kind, "focus"), nil
}
func (controller *recordingClientController) StartOrFocusFlutter(
_ context.Context,
_ string,
) (clientprocess.Result, error) {
controller.mu.Lock()
defer controller.mu.Unlock()
controller.calls[OperationClientDetail]++
action := "focus"
if !controller.flutter {
action = "start"
controller.flutter = true
}
return clientResult(clientprocess.ClientFlutter, action), nil
}
func (controller *recordingClientController) callsFor(operation string) int {
controller.mu.Lock()
defer controller.mu.Unlock()
return controller.calls[operation]
}
func (controller *recordingClientController) totalCalls() int {
controller.mu.Lock()
defer controller.mu.Unlock()
total := 0
for _, calls := range controller.calls {
total += calls
}
return total
}
func clientResult(
kind clientprocess.ClientKind,
action string,
) clientprocess.Result {
return clientprocess.Result{
Record: clientprocess.Record{
SchemaVersion: clientprocess.RecordSchemaVersion,
Kind: kind,
State: clientprocess.StateConnected,
Connected: true,
},
Changed: true,
Action: action,
}
}
func newTestClientOperations(
t *testing.T,
controller ClientProcessController,
) *ClientOperations {
t.Helper()
store, err := agentstate.NewStore(
filepath.Join(t.TempDir(), "state.json"),
)
if err != nil {
t.Fatalf("NewStore: %v", err)
}
ledger, err := NewLedger(
context.Background(),
store,
"daemon-client-operations",
16,
)
if err != nil {
t.Fatalf("NewLedger: %v", err)
}
operations, err := NewClientOperations(controller, ledger)
if err != nil {
t.Fatalf("NewClientOperations: %v", err)
}
return operations
}
func clientOperationRequest(
messageID string,
commandID string,
operation string,
kind string,
capability string,
) *iop.AgentLocalEnvelope {
return &iop.AgentLocalEnvelope{
ProtocolVersion: ProtocolVersion,
Kind: iop.AgentLocalKind_AGENT_LOCAL_KIND_REQUEST,
MessageId: messageID,
Operation: operation,
Payload: &iop.AgentLocalEnvelope_Request{
Request: &iop.AgentLocalRequest{
CommandId: commandID,
Payload: &iop.AgentLocalRequest_Client{
Client: &iop.AgentLocalClientRequest{
ClientKind: kind,
Capability: capability,
},
},
},
},
}
}