694 lines
19 KiB
Go
694 lines
19 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"math"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
dispatchControlCapacity = 64
|
|
dispatchBulkCapacity = 2
|
|
dispatchCommandLimit = 500
|
|
dispatchBulkWorkers = 5
|
|
dispatchHTTPWorkers = 6
|
|
dispatchMaxDeadline = 59 * time.Second
|
|
)
|
|
|
|
// Only idempotent field setters belong in the background Mapping lane.
|
|
var mappingBulkFunctions = map[string]bool{
|
|
"SetText": true, "SetImage": true, "SetColor": true, "SetTextColour": true,
|
|
"SetTextVisibleOn": true, "SetTextVisibleOff": true,
|
|
"SetImageVisibleOn": true, "SetImageVisibleOff": true,
|
|
}
|
|
|
|
type dispatchResult struct {
|
|
OK bool `json:"ok"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
type dispatchJob struct {
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
epoch uint64
|
|
assignmentID string
|
|
matchID string
|
|
vmixURL string
|
|
requestID string
|
|
batch bool
|
|
bulk bool
|
|
commands []map[string]any
|
|
invalidReason string
|
|
timerRevision uint64
|
|
}
|
|
|
|
// One dispatcher belongs to one authenticated WebSocket connection. Its two
|
|
// coordinators and bounded per-job chain workers never grow with packet count.
|
|
type commandDispatcher struct {
|
|
agent *Agent
|
|
ws *wsConn
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
mu sync.Mutex
|
|
assignment context.Context
|
|
cancelAssignment context.CancelFunc
|
|
epoch uint64
|
|
assignmentID string
|
|
matchID string
|
|
controls chan *dispatchJob
|
|
bulk chan *dispatchJob
|
|
permits chan struct{}
|
|
controlPending int
|
|
changed chan struct{}
|
|
workers sync.WaitGroup
|
|
timerRevision uint64
|
|
targetRevisions map[string]uint64
|
|
targetLocks map[string]chan struct{}
|
|
}
|
|
|
|
func newCommandDispatcher(a *Agent, ws *wsConn) *commandDispatcher {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
assignment, cancelAssignment := context.WithCancel(ctx)
|
|
a.mu.RLock()
|
|
assignmentID, matchID := a.state.AssignmentID, a.state.MatchID
|
|
a.mu.RUnlock()
|
|
d := &commandDispatcher{agent: a, ws: ws, ctx: ctx, cancel: cancel,
|
|
assignment: assignment, cancelAssignment: cancelAssignment,
|
|
assignmentID: assignmentID, matchID: matchID,
|
|
controls: make(chan *dispatchJob, dispatchControlCapacity),
|
|
bulk: make(chan *dispatchJob, dispatchBulkCapacity),
|
|
permits: make(chan struct{}, dispatchHTTPWorkers), changed: make(chan struct{}),
|
|
targetRevisions: map[string]uint64{}, targetLocks: map[string]chan struct{}{}}
|
|
d.workers.Add(2)
|
|
go d.runLane(d.controls, false)
|
|
go d.runLane(d.bulk, true)
|
|
return d
|
|
}
|
|
|
|
func (d *commandDispatcher) close() {
|
|
d.cancel()
|
|
d.workers.Wait()
|
|
}
|
|
|
|
func (d *commandDispatcher) notifyLocked() {
|
|
close(d.changed)
|
|
d.changed = make(chan struct{})
|
|
}
|
|
|
|
func (d *commandDispatcher) replaceAssignment(assignmentID, matchID string) {
|
|
d.mu.Lock()
|
|
if d.assignmentID != assignmentID || d.matchID != matchID {
|
|
d.cancelAssignment()
|
|
d.assignment, d.cancelAssignment = context.WithCancel(d.ctx)
|
|
d.epoch++
|
|
d.assignmentID, d.matchID = assignmentID, matchID
|
|
d.notifyLocked()
|
|
}
|
|
d.mu.Unlock()
|
|
}
|
|
|
|
// Non-command messages stay immediate. In particular, assignment changes cancel
|
|
// running HTTP requests before updating the Agent state or accepting more work.
|
|
func (d *commandDispatcher) handle(msg map[string]any) error {
|
|
typ := stringValue(msg["type"])
|
|
switch typ {
|
|
case "vmix.command", "vmix.batch":
|
|
return d.enqueue(msg)
|
|
case "match.assign":
|
|
d.replaceAssignment(stringValue(msg["assignment_id"]), stringValue(msg["game_id"]))
|
|
case "hello.ok":
|
|
if device, ok := msg["device"].(map[string]any); ok {
|
|
d.replaceAssignment(stringValue(device["assignment_id"]), stringValue(device["current_match_id"]))
|
|
}
|
|
case "pairing.revoked", "device.deactivated":
|
|
d.replaceAssignment("", "")
|
|
}
|
|
return d.agent.handleMessage(d.ws, msg)
|
|
}
|
|
|
|
func dispatchDeadline(raw any) (time.Duration, bool) {
|
|
if raw == nil {
|
|
return 0, false
|
|
}
|
|
var value float64
|
|
switch n := raw.(type) {
|
|
case float64:
|
|
value = n
|
|
case int:
|
|
value = float64(n)
|
|
case int64:
|
|
value = float64(n)
|
|
case json.Number:
|
|
parsed, err := strconv.ParseFloat(string(n), 64)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
value = parsed
|
|
default:
|
|
return 0, false
|
|
}
|
|
if math.IsNaN(value) || math.IsInf(value, 0) || value <= 0 || value != math.Trunc(value) {
|
|
return 0, false
|
|
}
|
|
if value > float64(dispatchMaxDeadline/time.Millisecond) {
|
|
return dispatchMaxDeadline, true
|
|
}
|
|
return time.Duration(value) * time.Millisecond, true
|
|
}
|
|
|
|
func (d *commandDispatcher) enqueue(msg map[string]any) error {
|
|
batch := stringValue(msg["type"]) == "vmix.batch"
|
|
commands := []map[string]any{}
|
|
if batch {
|
|
raw, ok := msg["commands"].([]any)
|
|
if !ok {
|
|
return d.reject(msg, nil, "invalid_commands")
|
|
}
|
|
if len(raw) > dispatchCommandLimit {
|
|
return d.reject(msg, nil, "too_many_commands")
|
|
}
|
|
for _, value := range raw {
|
|
command, _ := value.(map[string]any)
|
|
commands = append(commands, command)
|
|
}
|
|
} else {
|
|
command, _ := msg["command"].(map[string]any)
|
|
commands = append(commands, command)
|
|
}
|
|
bulk := false
|
|
if raw, present := msg["mapping_bulk"]; present {
|
|
flag, ok := raw.(bool)
|
|
if !ok || (flag && !batch) {
|
|
return d.reject(msg, commands, "invalid_mapping_bulk")
|
|
}
|
|
bulk = flag
|
|
}
|
|
deadline := dispatchMaxDeadline
|
|
if bulk {
|
|
assignmentID, assignmentOK := msg["assignment_id"].(string)
|
|
matchID, matchOK := msg["match_id"].(string)
|
|
if !assignmentOK || !matchOK || strings.TrimSpace(assignmentID) == "" || strings.TrimSpace(matchID) == "" {
|
|
return d.reject(msg, commands, "missing_mapping_scope")
|
|
}
|
|
if raw, present := msg["mapping_deadline_ms"]; present {
|
|
var ok bool
|
|
deadline, ok = dispatchDeadline(raw)
|
|
if !ok {
|
|
return d.reject(msg, commands, "invalid_mapping_deadline")
|
|
}
|
|
}
|
|
}
|
|
d.mu.Lock()
|
|
ctx, cancel := context.WithTimeout(d.assignment, deadline)
|
|
job := &dispatchJob{ctx: ctx, cancel: cancel, epoch: d.epoch,
|
|
assignmentID: d.assignmentID, matchID: d.matchID,
|
|
requestID: stringValue(msg["request_id"]), batch: batch, bulk: bulk, commands: commands,
|
|
timerRevision: d.timerRevision}
|
|
if incoming := stringValue(msg["assignment_id"]); incoming != "" {
|
|
job.assignmentID = incoming
|
|
if incoming != d.assignmentID {
|
|
job.invalidReason = "assignment_mismatch"
|
|
}
|
|
}
|
|
if incoming := stringValue(msg["match_id"]); incoming != "" {
|
|
job.matchID = incoming
|
|
if incoming != d.matchID {
|
|
job.invalidReason = "match_mismatch"
|
|
}
|
|
}
|
|
d.agent.mu.RLock()
|
|
job.vmixURL = d.agent.cfg.VmixURL
|
|
currentScope := d.agent.currentWS == d.ws && d.agent.state.AssignmentID == job.assignmentID && d.agent.state.MatchID == job.matchID && job.ctx.Err() == nil && d.ctx.Err() == nil
|
|
d.agent.mu.RUnlock()
|
|
if !bulk {
|
|
d.controlPending++
|
|
d.notifyLocked()
|
|
}
|
|
lane := d.controls
|
|
if bulk {
|
|
lane = d.bulk
|
|
}
|
|
select {
|
|
case lane <- job:
|
|
// Fence only accepted, authorized timer work. Rejected/overflow packets
|
|
// cannot suppress an otherwise valid Mapping field.
|
|
if !bulk && job.invalidReason == "" && currentScope {
|
|
for _, command := range commands {
|
|
if dispatchTimerFunction(dispatchFunction(command)) {
|
|
d.timerRevision++
|
|
for _, target := range d.commandTargetKeys(command) {
|
|
d.targetRevisions[target] = d.timerRevision
|
|
}
|
|
}
|
|
}
|
|
}
|
|
d.mu.Unlock()
|
|
return nil
|
|
default:
|
|
if !bulk {
|
|
d.controlPending--
|
|
d.notifyLocked()
|
|
}
|
|
d.mu.Unlock()
|
|
cancel()
|
|
reason := "control_queue_full"
|
|
if bulk {
|
|
reason = "mapping_queue_full"
|
|
}
|
|
return d.reject(msg, commands, reason)
|
|
}
|
|
}
|
|
|
|
func (d *commandDispatcher) reject(msg map[string]any, commands []map[string]any, reason string) error {
|
|
rows := make([]dispatchResult, len(commands))
|
|
for i := range rows {
|
|
rows[i] = dispatchResult{Reason: reason}
|
|
}
|
|
batch := stringValue(msg["type"]) == "vmix.batch"
|
|
payload := map[string]any{"type": "command.ack", "request_id": stringValue(msg["request_id"]),
|
|
"assignment_id": stringValue(msg["assignment_id"]), "match_id": stringValue(msg["match_id"]),
|
|
"ok": false, "reason": reason}
|
|
if batch {
|
|
payload["type"] = "command.batch.ack"
|
|
payload["results"] = rows
|
|
payload["applied"] = 0
|
|
payload["failed"] = len(rows)
|
|
}
|
|
return d.write(payload)
|
|
}
|
|
|
|
func (d *commandDispatcher) runLane(lane <-chan *dispatchJob, bulk bool) {
|
|
defer d.workers.Done()
|
|
for {
|
|
select {
|
|
case <-d.ctx.Done():
|
|
return
|
|
case job := <-lane:
|
|
d.runJob(job)
|
|
job.cancel()
|
|
if !bulk {
|
|
d.mu.Lock()
|
|
d.controlPending--
|
|
d.notifyLocked()
|
|
d.mu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (d *commandDispatcher) reason(job *dispatchJob) string {
|
|
if d.ctx.Err() != nil {
|
|
return "connection_closed"
|
|
}
|
|
d.mu.Lock()
|
|
same := job.epoch == d.epoch && job.assignmentID == d.assignmentID && job.matchID == d.matchID
|
|
d.mu.Unlock()
|
|
if job.invalidReason != "" {
|
|
return job.invalidReason
|
|
}
|
|
if !same {
|
|
return "assignment_changed"
|
|
}
|
|
if job.ctx.Err() == context.DeadlineExceeded {
|
|
return "mapping_deadline_exceeded"
|
|
}
|
|
if job.ctx.Err() != nil {
|
|
return "assignment_changed"
|
|
}
|
|
d.agent.mu.RLock()
|
|
same = d.agent.currentWS == d.ws && d.agent.state.AssignmentID == job.assignmentID && d.agent.state.MatchID == job.matchID && d.agent.cfg.VmixURL == job.vmixURL
|
|
d.agent.mu.RUnlock()
|
|
if !same {
|
|
return "stale_command_scope"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (d *commandDispatcher) acquire(job *dispatchJob) bool {
|
|
if !job.bulk {
|
|
select {
|
|
case d.permits <- struct{}{}:
|
|
return true
|
|
case <-job.ctx.Done():
|
|
return false
|
|
}
|
|
}
|
|
for {
|
|
d.mu.Lock()
|
|
changed := d.changed
|
|
if d.controlPending == 0 {
|
|
select {
|
|
case d.permits <- struct{}{}:
|
|
d.mu.Unlock()
|
|
return true
|
|
default:
|
|
}
|
|
}
|
|
d.mu.Unlock()
|
|
select {
|
|
case <-changed:
|
|
case <-job.ctx.Done():
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
func (d *commandDispatcher) release() {
|
|
<-d.permits
|
|
d.mu.Lock()
|
|
d.notifyLocked()
|
|
d.mu.Unlock()
|
|
}
|
|
|
|
func dispatchFunction(command map[string]any) string {
|
|
function := stringValue(command["Function"])
|
|
if function == "" {
|
|
function = stringValue(command["function"])
|
|
}
|
|
return function
|
|
}
|
|
|
|
func dispatchTimerFunction(function string) bool {
|
|
switch function {
|
|
case "SetCountdown", "StartCountdown", "PauseCountdown", "StopCountdown":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Include every matching inventory identity. A duplicate title conservatively
|
|
// joins its possible Inputs instead of allowing a title/number alias bypass.
|
|
func (d *commandDispatcher) commandTargetKeys(command map[string]any) []string {
|
|
input := strings.TrimSpace(stringValue(command["Input"]))
|
|
field := strings.ToLower(strings.TrimSpace(stringValue(command["SelectedName"])))
|
|
aliases := map[string]bool{strings.ToLower(input): true}
|
|
d.agent.mu.RLock()
|
|
for _, item := range d.agent.inventory.Inputs {
|
|
if strings.EqualFold(input, item.Key) || strings.EqualFold(input, item.Number) || strings.EqualFold(input, item.Title) {
|
|
for _, alias := range []string{item.Key, item.Number, item.Title} {
|
|
if alias = strings.TrimSpace(alias); alias != "" {
|
|
aliases[strings.ToLower(alias)] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
d.agent.mu.RUnlock()
|
|
keys := make([]string, 0, len(aliases))
|
|
for alias := range aliases {
|
|
keys = append(keys, alias+"\x00"+field)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
func (d *commandDispatcher) lockTargets(job *dispatchJob, keys []string) (func(), bool) {
|
|
d.mu.Lock()
|
|
locks := make([]chan struct{}, len(keys))
|
|
for i, key := range keys {
|
|
lock := d.targetLocks[key]
|
|
if lock == nil {
|
|
lock = make(chan struct{}, 1)
|
|
d.targetLocks[key] = lock
|
|
}
|
|
locks[i] = lock
|
|
}
|
|
d.mu.Unlock()
|
|
held := 0
|
|
release := func() {
|
|
for i := held - 1; i >= 0; i-- {
|
|
<-locks[i]
|
|
}
|
|
}
|
|
for _, lock := range locks {
|
|
select {
|
|
case lock <- struct{}{}:
|
|
held++
|
|
case <-job.ctx.Done():
|
|
release()
|
|
return nil, false
|
|
}
|
|
}
|
|
return release, true
|
|
}
|
|
|
|
func (d *commandDispatcher) execute(job *dispatchJob, command map[string]any) dispatchResult {
|
|
if reason := d.reason(job); reason != "" {
|
|
return dispatchResult{Reason: reason}
|
|
}
|
|
function := dispatchFunction(command)
|
|
if function == "" {
|
|
return dispatchResult{Reason: "missing_function"}
|
|
}
|
|
if job.bulk && !mappingBulkFunctions[function] {
|
|
return dispatchResult{Reason: "unsafe_mapping_function"}
|
|
}
|
|
if job.bulk && (strings.TrimSpace(stringValue(command["Input"])) == "" || strings.TrimSpace(stringValue(command["SelectedName"])) == "") {
|
|
return dispatchResult{Reason: "missing_mapping_target"}
|
|
}
|
|
if !d.acquire(job) {
|
|
return dispatchResult{Reason: d.reason(job)}
|
|
}
|
|
defer d.release()
|
|
targets := d.commandTargetKeys(command)
|
|
unlockTargets, locked := d.lockTargets(job, targets)
|
|
if !locked {
|
|
return dispatchResult{Reason: d.reason(job)}
|
|
}
|
|
defer unlockTargets()
|
|
if reason := d.reason(job); reason != "" {
|
|
return dispatchResult{Reason: reason}
|
|
}
|
|
if job.bulk {
|
|
d.mu.Lock()
|
|
protected := false
|
|
for _, target := range targets {
|
|
if d.targetRevisions[target] > job.timerRevision {
|
|
protected = true
|
|
break
|
|
}
|
|
}
|
|
d.mu.Unlock()
|
|
if protected {
|
|
return dispatchResult{Reason: "native_countdown_protected"}
|
|
}
|
|
}
|
|
params := url.Values{}
|
|
params.Set("Function", function)
|
|
for key, value := range command {
|
|
if strings.EqualFold(key, "function") || value == nil {
|
|
continue
|
|
}
|
|
text := stringValue(value)
|
|
if strings.EqualFold(key, "Value") || text != "" {
|
|
params.Set(key, text)
|
|
}
|
|
}
|
|
requestCtx, cancel := context.WithTimeout(job.ctx, 3*time.Second)
|
|
status := probeVmixContext(requestCtx, job.vmixURL, params)
|
|
cancel()
|
|
if reason := d.reason(job); reason != "" {
|
|
return dispatchResult{Reason: reason}
|
|
}
|
|
// State updates share the authority lock ordering with scope checks.
|
|
d.mu.Lock()
|
|
d.agent.mu.Lock()
|
|
current := job.epoch == d.epoch && job.ctx.Err() == nil && d.ctx.Err() == nil && d.agent.currentWS == d.ws &&
|
|
d.agent.state.AssignmentID == job.assignmentID && d.agent.state.MatchID == job.matchID && d.agent.cfg.VmixURL == job.vmixURL
|
|
if current {
|
|
// A bad field is a command failure, not a failed base-API health probe.
|
|
if status.Connected {
|
|
d.agent.state.Vmix = status
|
|
}
|
|
d.agent.state.LastCommand = function
|
|
d.agent.state.LastCommandOK = status.Connected
|
|
d.agent.state.LastCommandAt = time.Now()
|
|
d.agent.state.LastCommandDetail = status.Error
|
|
}
|
|
d.agent.mu.Unlock()
|
|
d.mu.Unlock()
|
|
if !current {
|
|
return dispatchResult{Reason: "stale_command_scope"}
|
|
}
|
|
return dispatchResult{OK: status.Connected, Reason: status.Error}
|
|
}
|
|
|
|
func (d *commandDispatcher) runJob(job *dispatchJob) {
|
|
results := make([]dispatchResult, len(job.commands))
|
|
for i := range results {
|
|
results[i].Reason = "mapping_not_attempted"
|
|
}
|
|
// Chains preserve caller order for one physical target; different targets
|
|
// may use the bounded HTTP pool concurrently, including legacy batches.
|
|
chains := [][]int{}
|
|
parents := make([]int, len(job.commands))
|
|
var root func(int) int
|
|
root = func(index int) int {
|
|
if parents[index] != index {
|
|
parents[index] = root(parents[index])
|
|
}
|
|
return parents[index]
|
|
}
|
|
targets := map[string]int{}
|
|
for index, command := range job.commands {
|
|
parents[index] = index
|
|
for _, target := range d.commandTargetKeys(command) {
|
|
if previous, exists := targets[target]; exists {
|
|
parents[root(index)] = root(previous)
|
|
} else {
|
|
targets[target] = index
|
|
}
|
|
}
|
|
}
|
|
groups := map[int]int{}
|
|
for index := range job.commands {
|
|
parent := root(index)
|
|
chain, exists := groups[parent]
|
|
if !exists {
|
|
chain = len(chains)
|
|
groups[parent] = chain
|
|
chains = append(chains, []int{})
|
|
}
|
|
chains[chain] = append(chains[chain], index)
|
|
}
|
|
workers := dispatchHTTPWorkers
|
|
if job.bulk {
|
|
workers = dispatchBulkWorkers
|
|
}
|
|
if len(chains) < workers {
|
|
workers = len(chains)
|
|
}
|
|
work := make(chan []int)
|
|
var group sync.WaitGroup
|
|
group.Add(workers)
|
|
for i := 0; i < workers; i++ {
|
|
go func() {
|
|
defer group.Done()
|
|
for chain := range work {
|
|
for _, index := range chain {
|
|
results[index] = d.execute(job, job.commands[index])
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
for _, chain := range chains {
|
|
select {
|
|
case work <- chain:
|
|
case <-job.ctx.Done():
|
|
// Workers still drain bounded chains with cancelled scope, producing
|
|
// explicit false rows; no command is silently treated as successful.
|
|
work <- chain
|
|
}
|
|
}
|
|
close(work)
|
|
group.Wait()
|
|
if reason := d.reason(job); reason != "" {
|
|
for i := range results {
|
|
if reason != "mapping_deadline_exceeded" || results[i].Reason == "mapping_not_attempted" {
|
|
results[i] = dispatchResult{Reason: reason}
|
|
}
|
|
}
|
|
}
|
|
applied := 0
|
|
for _, row := range results {
|
|
if row.OK {
|
|
applied++
|
|
}
|
|
}
|
|
payload := map[string]any{"type": "command.ack", "request_id": job.requestID,
|
|
"assignment_id": job.assignmentID, "match_id": job.matchID, "ok": applied == len(results)}
|
|
if job.batch {
|
|
payload["type"] = "command.batch.ack"
|
|
payload["results"] = results
|
|
payload["applied"] = applied
|
|
payload["failed"] = len(results) - applied
|
|
} else if len(results) == 1 {
|
|
payload["reason"] = results[0].Reason
|
|
}
|
|
if applied != len(results) && job.batch {
|
|
if reason := d.reason(job); reason != "" {
|
|
payload["reason"] = reason
|
|
}
|
|
}
|
|
if job.batch {
|
|
d.mu.Lock()
|
|
d.agent.mu.Lock()
|
|
if job.epoch == d.epoch && d.ctx.Err() == nil && d.agent.currentWS == d.ws &&
|
|
d.agent.state.AssignmentID == job.assignmentID && d.agent.state.MatchID == job.matchID {
|
|
d.agent.state.LastCommand = "vMix batch"
|
|
d.agent.state.LastCommandAt = time.Now()
|
|
d.agent.state.LastCommandOK = applied == len(results)
|
|
d.agent.state.LastCommandDetail = ""
|
|
if applied != len(results) {
|
|
d.agent.state.LastCommandDetail = "Some commands failed"
|
|
}
|
|
}
|
|
d.agent.mu.Unlock()
|
|
d.mu.Unlock()
|
|
}
|
|
if job.bulk {
|
|
reason := "ok"
|
|
for _, row := range results {
|
|
if !row.OK {
|
|
reason = row.Reason
|
|
break
|
|
}
|
|
}
|
|
d.agent.logf("Mapping batch: %d commands, confirmed %d, failed %d, reason %s", len(results), applied, len(results)-applied, reason)
|
|
} else {
|
|
for index, command := range job.commands {
|
|
reason := results[index].Reason
|
|
if results[index].OK {
|
|
reason = "ok"
|
|
}
|
|
d.agent.logf("vMix control: %s · %s · %s · %s", dispatchLogTarget(dispatchFunction(command)), dispatchLogTarget(stringValue(command["Input"])), dispatchLogTarget(stringValue(command["SelectedName"])), reason)
|
|
}
|
|
}
|
|
// Recheck immediately before ACK construction leaves this job. The original
|
|
// captured scope remains attached, so a late packet cannot acknowledge a new
|
|
// assignment. A deadline still preserves genuinely completed positional rows.
|
|
if reason := d.reason(job); reason != "" && reason != "mapping_deadline_exceeded" {
|
|
for i := range results {
|
|
results[i] = dispatchResult{Reason: reason}
|
|
}
|
|
payload["ok"] = false
|
|
payload["reason"] = reason
|
|
if job.batch {
|
|
payload["applied"] = 0
|
|
payload["failed"] = len(results)
|
|
}
|
|
}
|
|
_ = d.write(payload)
|
|
}
|
|
|
|
func dispatchLogTarget(value string) string {
|
|
if strings.Contains(value, "://") {
|
|
return "<URL>"
|
|
}
|
|
value = strings.ReplaceAll(strings.ReplaceAll(value, "\r", " "), "\n", " ")
|
|
if len(value) > 100 {
|
|
return value[:100]
|
|
}
|
|
return value
|
|
}
|
|
|
|
func (d *commandDispatcher) write(payload map[string]any) error {
|
|
d.agent.mu.RLock()
|
|
current := d.agent.currentWS == d.ws && d.ctx.Err() == nil
|
|
d.agent.mu.RUnlock()
|
|
if !current {
|
|
return nil
|
|
}
|
|
if err := d.ws.WriteJSON(payload); err != nil {
|
|
d.cancel()
|
|
_ = d.ws.Close()
|
|
return err
|
|
}
|
|
return nil
|
|
}
|