fix(picoclaw): cancel runtime lifecycle on mode switch

Cancel PicoClaw start/readiness waits when MCP takes control, clear held manual input state after failed release reports, and keep paste duration below the control-mode wait budget.

Also add MCP copy failure translations and avoid reconnect effect churn when the locale changes.
This commit is contained in:
SiYue-ZO
2026-07-20 21:56:31 +08:00
committed by Guoguo
parent d578c63036
commit 57e1f6a4d6
31 changed files with 343 additions and 31 deletions

View File

@@ -1,6 +1,9 @@
package hid
import "testing"
import (
"testing"
"time"
)
func TestReportLengthValidation(t *testing.T) {
h := &Hid{}
@@ -14,3 +17,12 @@ func TestReportLengthValidation(t *testing.T) {
t.Fatal("expected absolute mouse length error")
}
}
func TestPasteDurationLeavesModeSwitchMargin(t *testing.T) {
if maxPasteDuration >= 30*time.Second {
t.Fatalf("maxPasteDuration = %s, want below 30s mode switch wait budget", maxPasteDuration)
}
if got := time.Duration(maxPasteContentRunes) * defaultPasteDelay; got > maxPasteDuration {
t.Fatalf("max paste content duration = %s, want <= %s", got, maxPasteDuration)
}
}

View File

@@ -23,7 +23,7 @@ type PasteReq struct {
const (
defaultPasteDelay = 30 * time.Millisecond
maxPasteDuration = 30 * time.Second
maxPasteDuration = 25 * time.Second
maxPasteContentRunes = int(maxPasteDuration / defaultPasteDelay)
)
@@ -193,7 +193,7 @@ func (s *Service) Paste(c *gin.Context) {
}
}
if time.Duration(typeableRunes)*defaultPasteDelay > maxPasteDuration {
rsp.ErrRsp(c, -2, "paste duration exceeds 30s")
rsp.ErrRsp(c, -2, "paste duration exceeds 25s")
return
}

View File

@@ -429,6 +429,10 @@ func (s *ManualSession) complete(generation uint64, kind ManualReportKind, held
case ManualAbsoluteMouse:
s.absoluteMouseHeld = held
}
} else {
s.keyboardHeld = false
s.relativeMouseHeld = false
s.absoluteMouseHeld = false
}
releaseControl, end, startCooldown := s.finishIfIdleLocked()
s.mu.Unlock()

View File

@@ -162,6 +162,49 @@ func TestFailedHeldManualInputDoesNotRemainHeld(t *testing.T) {
release()
}
func TestFailedReleaseReportClearsHeldManualInput(t *testing.T) {
tests := []struct {
name string
kind ManualReportKind
}{
{name: "keyboard", kind: ManualKeyboard},
{name: "relative mouse", kind: ManualRelativeMouse},
{name: "absolute mouse", kind: ManualAbsoluteMouse},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
now := time.Unix(260, 0)
coordinator := newCoordinator(time.Second, func() time.Time { return now })
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
manual := NewManualSession(control, coordinator)
defer manual.Close()
down, err := manual.Reserve(context.Background(), tc.kind, true, nil)
if err != nil {
t.Fatal(err)
}
down.Complete(true)
up, err := manual.Reserve(context.Background(), tc.kind, false, nil)
if err != nil {
t.Fatal(err)
}
up.Complete(false)
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) {
t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive)
}
now = now.Add(2 * time.Second)
_, release, err := coordinator.BeginMCP(context.Background(), OperationHID)
if err != nil {
t.Fatalf("failed release report left held input active: %v", err)
}
release()
})
}
}
func TestPointerMoveWithoutButtonsDoesNotStartCooldown(t *testing.T) {
coordinator := newCoordinator(time.Second, time.Now)
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
@@ -327,6 +370,40 @@ func TestBlockedActiveSessionStillAllowsReleaseReport(t *testing.T) {
up.Complete(true)
}
func TestBlockedActiveSessionFailedReleaseClearsHeldReport(t *testing.T) {
now := time.Unix(325, 0)
coordinator := newCoordinator(time.Second, func() time.Time { return now })
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw)
manual := NewManualSession(control, coordinator)
defer manual.Close()
down, err := manual.Reserve(context.Background(), ManualKeyboard, true, func(controlmode.Mode) bool { return true })
if err != nil {
t.Fatal(err)
}
down.Complete(true)
if _, err := manual.Reserve(context.Background(), ManualKeyboard, true, func(controlmode.Mode) bool { return false }); !errors.Is(err, ErrManualInputBlocked) {
t.Fatalf("new held report error = %v, want %v", err, ErrManualInputBlocked)
}
up, err := manual.Reserve(context.Background(), ManualKeyboard, false, func(controlmode.Mode) bool { return false })
if err != nil {
t.Fatalf("release report was blocked: %v", err)
}
up.Complete(false)
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) {
t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive)
}
now = now.Add(2 * time.Second)
_, release, err := coordinator.BeginMCP(context.Background(), OperationHID)
if err != nil {
t.Fatalf("failed blocked release report left held input active: %v", err)
}
release()
}
func TestReadOnlyMCPAllowedDuringManualControl(t *testing.T) {
coordinator := newCoordinator(defaultManualCooldown, time.Now)
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)

View File

@@ -104,6 +104,17 @@ func (s *Service) beginControlOperation(parent context.Context) (context.Context
}
}
func (s *Service) beginRuntimeLifecycleOperation(parent context.Context) (context.Context, func()) {
if parent == nil {
parent = context.Background()
}
if s == nil || s.operations == nil {
ctx, cancel := context.WithCancelCause(parent)
return ctx, func() { cancel(context.Canceled) }
}
return s.operations.begin(parent)
}
func (s *Service) CancelActiveControlOperations() int {
if s == nil || s.operations == nil {
return 0
@@ -115,6 +126,22 @@ func (s *Service) CancelActiveControlOperations() int {
return count
}
func runtimeLifecycleOperationError(ctx context.Context) *PicoclawError {
if ctx == nil || ctx.Err() == nil {
return nil
}
cause := context.Cause(ctx)
switch {
case errors.Is(cause, errControlModeSwitch):
return newPicoclawError(CodeControlModeConflict, "PicoClaw runtime lifecycle canceled because the control mode is switching")
case errors.Is(cause, context.DeadlineExceeded):
return newPicoclawError(CodeRuntimeUnavailable, "PicoClaw runtime lifecycle timed out")
default:
return newPicoclawError(CodeRuntimeUnavailable, "PicoClaw runtime lifecycle was canceled")
}
}
func controlOperationError(ctx context.Context) *PicoclawError {
if ctx == nil || ctx.Err() == nil {
return nil

View File

@@ -67,6 +67,70 @@ func TestControlModeSwitchCancelsActiveWait(t *testing.T) {
}
}
func TestControlModeSwitchCancelsRuntimeLifecycle(t *testing.T) {
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw)
service := &Service{
control: control,
operations: newControlOperationTracker(),
}
operationCtx, releaseOperation := service.beginRuntimeLifecycleOperation(context.Background())
releaseMode, modeErr := service.acquireControlMode()
if modeErr != nil {
t.Fatal(modeErr)
}
lifecycleDone := make(chan *PicoclawError, 1)
go func() {
<-operationCtx.Done()
lifecycleErr := runtimeLifecycleOperationError(operationCtx)
releaseOperation()
releaseMode()
lifecycleDone <- lifecycleErr
}()
switchDone := make(chan error, 1)
go func() {
switchDone <- control.Switch(controlmode.ModeMCP, func() error {
service.CancelActiveControlOperations()
return nil
})
}()
select {
case err := <-switchDone:
if err != nil {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("control mode switch did not cancel the runtime lifecycle operation")
}
select {
case lifecycleErr := <-lifecycleDone:
if lifecycleErr == nil || lifecycleErr.Code != CodeControlModeConflict {
t.Fatalf("lifecycle error = %+v, want %s", lifecycleErr, CodeControlModeConflict)
}
case <-time.After(time.Second):
t.Fatal("canceled runtime lifecycle operation did not return")
}
if got := control.Current(); got != controlmode.ModeMCP {
t.Fatalf("mode = %q, want %q", got, controlmode.ModeMCP)
}
}
func TestRuntimeReadyWaitHonorsLifecycleCancellation(t *testing.T) {
service := &Service{}
ctx, cancel := context.WithCancelCause(context.Background())
cancel(errControlModeSwitch)
err := service.waitForRuntimeReadyContext(ctx, time.Hour)
if err == nil || err.Code != CodeControlModeConflict {
t.Fatalf("error = %+v, want %s", err, CodeControlModeConflict)
}
}
func TestWaitDurationIsBounded(t *testing.T) {
service := &Service{}
_, err := service.executeAction(context.Background(), Action{

View File

@@ -35,16 +35,24 @@ func (s *Service) StartRuntime(c *gin.Context) {
}
defer releaseControl()
operationCtx, releaseOperation := s.beginRuntimeLifecycleOperation(c.Request.Context())
defer releaseOperation()
unlockLifecycle := s.lockRuntimeLifecycle()
defer unlockLifecycle()
if lifecycleErr := runtimeLifecycleOperationError(operationCtx); lifecycleErr != nil {
writePicoclawErrorWithData(c, lifecycleErr, gin.H{"status": s.runtimeStatus()})
return
}
if currentStatus := s.runtime.Get(); currentStatus.Installing {
runtimeErr := newPicoclawError(CodeRuntimeUnavailable, "picoclaw installation is in progress")
writePicoclawErrorWithData(c, runtimeErr, gin.H{"status": s.runtimeStatus()})
return
}
if readyErr := s.ensureRuntimeReadyForLifecycle(); readyErr == nil {
if readyErr := s.ensureRuntimeReadyForLifecycleContext(operationCtx); readyErr == nil {
s.setRuntimeIntentDesired(true, "web")
status := s.runtimeStatus()
log.WithFields(log.Fields{
@@ -67,8 +75,9 @@ func (s *Service) StartRuntime(c *gin.Context) {
status.CheckedAt = time.Now()
})
command, output, startErr := s.startRuntime()
command, output, startErr := s.startRuntimeContext(operationCtx)
if startErr != nil {
s.markRuntimeLifecycleCanceled(startErr)
s.setRuntimeIntentError(startErr.Message)
status := s.runtimeStatus()
log.WithFields(log.Fields{

View File

@@ -14,9 +14,19 @@ import (
)
func (s *Service) startRuntime() (string, string, *PicoclawError) {
return s.startRuntimeContext(context.Background())
}
func (s *Service) startRuntimeContext(ctx context.Context) (string, string, *PicoclawError) {
if ctx == nil {
ctx = context.Background()
}
if s == nil {
return "", "", newPicoclawError(CodeRuntimeStartFailed, "picoclaw service is unavailable")
}
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
return "", "", lifecycleErr
}
s.ensureDependencies()
if installed, statErr := isPicoclawInstalled(); statErr != nil {
@@ -77,12 +87,15 @@ func (s *Service) startRuntime() (string, string, *PicoclawError) {
}
command := scriptPath + " start"
ctx, cancel := context.WithTimeout(context.Background(), picoclawStartTimeout)
startCtx, cancel := context.WithTimeout(ctx, picoclawStartTimeout)
defer cancel()
output, execErr := exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput()
output, execErr := exec.CommandContext(startCtx, "sh", "-c", command).CombinedOutput()
trimmedOutput := strings.TrimSpace(string(output))
if execErr != nil {
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
return command, trimmedOutput, lifecycleErr
}
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Installed = true
@@ -96,17 +109,24 @@ func (s *Service) startRuntime() (string, string, *PicoclawError) {
return command, trimmedOutput, newPicoclawError(CodeRuntimeStartFailed, "failed to start picoclaw runtime")
}
time.Sleep(picoclawStartWaitPeriod)
if runtimeErr := s.waitForRuntimeReady(picoclawStartTimeout); runtimeErr != nil {
if lifecycleErr := sleepRuntimeLifecycleContext(ctx, picoclawStartWaitPeriod); lifecycleErr != nil {
return command, trimmedOutput, lifecycleErr
}
if runtimeErr := s.waitForRuntimeReadyContext(ctx, picoclawStartTimeout); runtimeErr != nil {
startErr := newPicoclawError(CodeRuntimeStartFailed, runtimeErr.Message)
failureStatus := "unavailable"
if cleanupErr := s.stopRuntimeAndVerify(true); cleanupErr != nil {
failureStatus = "error"
startErr.Message = fmt.Sprintf(
"%s; failed to stop partially started runtime: %v",
startErr.Message,
cleanupErr,
)
if runtimeErr.Code == CodeControlModeConflict {
startErr = runtimeErr
failureStatus = "starting"
} else {
if cleanupErr := s.stopRuntimeAndVerify(true); cleanupErr != nil {
failureStatus = "error"
startErr.Message = fmt.Sprintf(
"%s; failed to stop partially started runtime: %v",
startErr.Message,
cleanupErr,
)
}
}
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
@@ -121,6 +141,23 @@ func (s *Service) startRuntime() (string, string, *PicoclawError) {
return command, trimmedOutput, nil
}
func (s *Service) markRuntimeLifecycleCanceled(err *PicoclawError) {
if s == nil || err == nil || err.Code != CodeControlModeConflict {
return
}
s.ensureDependencies()
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Restoring = false
if isRuntimeLifecycleStatusPending(*status) {
status.Status = "stopped"
}
status.LastError = err.Message
status.CurrentSession = ""
status.CheckedAt = time.Now()
})
}
func (s *Service) stopRuntime() (string, string, *PicoclawError) {
if s == nil {
return "", "", newPicoclawError(CodeRuntimeStartFailed, "picoclaw service is unavailable")
@@ -183,11 +220,22 @@ func (s *Service) stopRuntime() (string, string, *PicoclawError) {
}
func (s *Service) waitForRuntimeReady(timeout time.Duration) *PicoclawError {
return s.waitForRuntimeReadyContext(context.Background(), timeout)
}
func (s *Service) waitForRuntimeReadyContext(ctx context.Context, timeout time.Duration) *PicoclawError {
if ctx == nil {
ctx = context.Background()
}
deadline := time.Now().Add(timeout)
var lastErr *PicoclawError
for {
runtimeErr := s.ensureRuntimeReadyForLifecycle()
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
return lifecycleErr
}
runtimeErr := s.ensureRuntimeReadyForLifecycleContext(ctx)
if runtimeErr == nil {
return nil
} else {
@@ -198,7 +246,9 @@ func (s *Service) waitForRuntimeReady(timeout time.Duration) *PicoclawError {
break
}
time.Sleep(picoclawReadyPollPeriod)
if lifecycleErr := sleepRuntimeLifecycleContext(ctx, picoclawReadyPollPeriod); lifecycleErr != nil {
return lifecycleErr
}
}
if lastErr != nil {
@@ -218,18 +268,31 @@ func resolvePicoclawStartScript() (string, error) {
}
func runPicoclawOnboard() (string, *PicoclawError) {
return runPicoclawOnboardContext(context.Background())
}
func runPicoclawOnboardContext(ctx context.Context) (string, *PicoclawError) {
if ctx == nil {
ctx = context.Background()
}
scriptPath, err := resolvePicoclawStartScript()
if err != nil {
return "", newPicoclawError(CodeRuntimeUnavailable, err.Error())
}
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
return "", lifecycleErr
}
command := scriptPath + " onboard"
ctx, cancel := context.WithTimeout(context.Background(), picoclawOnboardTimeout)
onboardCtx, cancel := context.WithTimeout(ctx, picoclawOnboardTimeout)
defer cancel()
output, execErr := exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput()
output, execErr := exec.CommandContext(onboardCtx, "sh", "-c", command).CombinedOutput()
trimmedOutput := strings.TrimSpace(string(output))
if execErr != nil {
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
return trimmedOutput, lifecycleErr
}
if trimmedOutput == "" {
trimmedOutput = execErr.Error()
}
@@ -239,6 +302,23 @@ func runPicoclawOnboard() (string, *PicoclawError) {
return trimmedOutput, nil
}
func sleepRuntimeLifecycleContext(ctx context.Context, delay time.Duration) *PicoclawError {
if ctx == nil {
ctx = context.Background()
}
if delay <= 0 {
return runtimeLifecycleOperationError(ctx)
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return runtimeLifecycleOperationError(ctx)
case <-timer.C:
return nil
}
}
func isPicoclawInstalled() (bool, error) {
info, err := os.Stat(picoclawBinaryPath)
if err == nil {

View File

@@ -1,6 +1,7 @@
package picoclaw
import (
"context"
"os"
"sync"
"time"
@@ -355,17 +356,24 @@ func (s *Service) lockRuntimeLifecycle() func() {
}
func (s *Service) ensureRuntimeReady() *PicoclawError {
return s.ensureRuntimeReadyWithProbeProtection(false)
return s.ensureRuntimeReadyWithProbeProtection(context.Background(), false)
}
func (s *Service) ensureRuntimeReadyForLifecycle() *PicoclawError {
return s.ensureRuntimeReadyWithProbeProtection(true)
return s.ensureRuntimeReadyForLifecycleContext(context.Background())
}
func (s *Service) ensureRuntimeReadyWithProbeProtection(allowLifecycleOverwrite bool) *PicoclawError {
func (s *Service) ensureRuntimeReadyForLifecycleContext(ctx context.Context) *PicoclawError {
return s.ensureRuntimeReadyWithProbeProtection(ctx, true)
}
func (s *Service) ensureRuntimeReadyWithProbeProtection(ctx context.Context, allowLifecycleOverwrite bool) *PicoclawError {
if s == nil {
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw service is unavailable")
}
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
return lifecycleErr
}
s.ensureDependencies()
setStatus := func(status RuntimeStatus) {
if allowLifecycleOverwrite {
@@ -440,7 +448,10 @@ func (s *Service) ensureRuntimeReadyWithProbeProtection(allowLifecycleOverwrite
}
if _, err := os.Stat(configPath); err != nil {
if os.IsNotExist(err) {
if _, onboardErr := runPicoclawOnboard(); onboardErr != nil {
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
return lifecycleErr
}
if _, onboardErr := runPicoclawOnboardContext(ctx); onboardErr != nil {
setStatus(RuntimeStatus{
Ready: false,
Installed: true,
@@ -455,6 +466,9 @@ func (s *Service) ensureRuntimeReadyWithProbeProtection(allowLifecycleOverwrite
})
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw model is not configured")
}
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
return lifecycleErr
}
if _, statErr := os.Stat(configPath); statErr == nil {
goto configReady
}