Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions native/WapiHost/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,8 @@ bool parseSetBatch(
std::vector<wtoken> seenTokens;
seenTokens.reserve(itemCount);

// Validate the complete batch before constructing any WAPI updates. A single
// malformed or duplicate token rejects the command without a partial write.
for (std::size_t itemIndex = 0; itemIndex < itemCount; ++itemIndex)
{
const std::size_t fieldIndex = 2U + itemIndex * 3U;
Expand Down Expand Up @@ -820,6 +822,8 @@ class WapiHost
return;
}

// Keepalive and event draining share the host thread with commands. This
// preserves the vendor library's single-threaded call boundary.
const int keepAliveResult = wKeepAlive();
if (keepAliveResult != WSUCCESS && keepAliveResult != WZERO)
{
Expand Down Expand Up @@ -1440,6 +1444,9 @@ int runHost()

CommandQueue queue;
std::atomic_bool stopReader = false;

// A dedicated reader prevents blocking stdin from starving WAPI keepalive and
// event polling. It never calls the vendor library; it only fills this queue.
std::thread reader([&queue, &stopReader]
{
std::string line;
Expand Down Expand Up @@ -1488,6 +1495,8 @@ int runHost()
inputEnded = queue.inputEnded && queue.lines.empty();
}

// Limit command work per turn so a busy managed client cannot starve
// console events indefinitely.
for (std::string& line : pending)
{
if (!host.handle(std::move(line)))
Expand Down
14 changes: 14 additions & 0 deletions src/WingSync.App/ViewModels/MainViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1005,6 +1005,8 @@ private async Task InitializeAsync()
}
else
{
// First run and failed recovery both start from a deliberately small,
// dry-run-safe topology rather than inferring a live configuration.
ChannelMappings.Add(new ChannelMappingViewModel(1, 1, "Channel 1"));
AttachMappingHandlers();
ApplySafeDefaultScopes();
Expand Down Expand Up @@ -1058,6 +1060,9 @@ private async Task DiscoverAsync(bool silent)
var previousStageSerial =
SelectedStageWing?.Wing.SerialNumber ??
activeConfiguration?.Monitor.ExpectedSerial;

// Rebuild the collection but restore assignments by pinned identity;
// list position and IP address may change between discovery passes.
DiscoveredWings.Clear();
foreach (var wing in result.Wings)
{
Expand Down Expand Up @@ -1314,6 +1319,8 @@ private async Task StartAsync()

try
{
// Persist a safe projection before starting. Session-only live and
// high-risk approvals must never survive an application restart.
await configStore.SaveAsync(CreatePersistedSafeConfiguration(configuration));
activeConfiguration = configuration;
ClearProblem();
Expand Down Expand Up @@ -1347,6 +1354,8 @@ private async Task ReviewAndConfirmLiveInitialSyncAsync(AppConfiguration configu
const int MaximumPreviewChanges = 3;
for (var attempt = 1; attempt <= MaximumPreviewChanges; attempt++)
{
// Each loop reviews one immutable snapshot. If either console changes,
// the coordinator replaces it and this method asks the operator again.
var preview = coordinator.PendingInitialPreview ??
throw new InvalidOperationException(
"Live synchronization was waiting without a valid fresh diff preview.");
Expand Down Expand Up @@ -1710,6 +1719,8 @@ private void NotifyTopologyChanged()
{
if (!suppressLiveSafetyReset)
{
// Any topology edit invalidates evidence gathered for the previous
// setup and revokes volatile live/high-risk consent.
connectionTestSucceeded = false;
hasCompletedDryRun = false;
var resetToSafeMode = false;
Expand Down Expand Up @@ -1987,6 +1998,9 @@ diagnosticEvent.Properties is null ||
diagnosticEvent.Code == "PARAMETER_BLOCKED"
? "Blocked"
: "Executable"));

// Diagnostics can arrive off the UI thread, so retain preview details in
// a bounded concurrent queue and materialize them only when opening a dialog.
var count = Interlocked.Increment(ref previewTokenAuditCount);
while (count > MaximumPreviewAuditEntries &&
previewTokenAudit.TryDequeue(out _))
Expand Down
6 changes: 6 additions & 0 deletions src/WingSync.Core/Planning/ScopeCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ public static bool TryMatch(TokenPath path, out SyncScope scope)
var segments = path.Segments;
var section = segments[2];

// Three-segment channel leaves are controls directly on the channel.
// Deeper paths are matched by their processing subtree below.
if (segments.Count == 3)
{
if (CustomLeaves.Contains(section))
Expand Down Expand Up @@ -613,6 +615,8 @@ public static bool TryRewrite(

if (source.Type == WingValueType.I)
{
// Native integer sidechain values encode regular input channels only;
// values outside that range represent non-channel choices and pass through.
var integerSourceChannel = source.AsInt32();
if (integerSourceChannel is < WingChannelLimits.FirstInput or > WingChannelLimits.LastInput)
{
Expand All @@ -637,6 +641,8 @@ public static bool TryRewrite(
out var prefix,
out var separator))
{
// External sources, self, off, and other named choices are semantic
// values rather than channel addresses and must remain unchanged.
return true;
}

Expand Down
6 changes: 6 additions & 0 deletions src/WingSync.Core/Planning/TokenPath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ public static bool TryParse(string? value, out TokenPath result)
}

var canonical = value.Trim();

// Require a single rooted representation. Rejecting alternate spellings
// keeps dictionary keys, fingerprints, and scope matching deterministic.
if (canonical.Length < 2
|| canonical[0] != '/'
|| canonical[^1] == '/'
Expand All @@ -66,6 +69,9 @@ public static bool TryParse(string? value, out TokenPath result)
}

var parsedSegments = canonical[1..].Split('/');

// Dot segments and whitespace could change meaning across consumers, so
// they are rejected rather than normalized silently.
if (parsedSegments.Length == 0
|| parsedSegments.Any(static segment =>
segment.Length == 0
Expand Down
13 changes: 13 additions & 0 deletions src/WingSync.Core/Planning/WritePlanner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ private static bool FloatsEquivalent(float expected, float actual, float toleran
return false;
}

// WAPI may quantize the same control differently at small and large
// magnitudes, so accept either an absolute or a scale-relative match.
var difference = MathF.Abs(expected - actual);
var relativeScale = MathF.Max(MathF.Abs(expected), MathF.Abs(actual));
return difference <= tolerance
Expand All @@ -327,6 +329,8 @@ public static WriteRisk Classify(SyncScope scope, TokenPath path)
if (path.Segments.Count > 0
&& path.Segments[0].Equals("io", StringComparison.OrdinalIgnoreCase))
{
// Physical I/O is always critical, even if a future catalog entry
// accidentally associates it with a less restrictive scope.
return WriteRisk.Critical;
}

Expand Down Expand Up @@ -399,6 +403,8 @@ public WritePlan Plan(
var inputOrder = 0;
var superseded = 0;

// First turn each source observation into at most one policy-approved
// target candidate. Rejected observations remain visible as plan issues.
foreach (var change in changes)
{
ArgumentNullException.ThrowIfNull(change);
Expand Down Expand Up @@ -453,12 +459,17 @@ public WritePlan Plan(
inputOrder++;
}

// An unresolved sidechain reference makes the whole processor group
// unsafe: applying only its remaining leaves could create a mixed model.
var candidates = candidatesByTarget.Values
.Where(candidate =>
GetDynamicSourceGroup(candidate.SourcePath) is not { } sourceGroup ||
!blockedDynamicSourceGroups.Contains(sourceGroup))
.ToList();
AddModelSafetyGuards(candidates);

// Preserve the first-seen order between independent processor groups,
// while enforcing guard -> model -> settings -> restore within a group.
var groupOrder = candidates
.GroupBy(static candidate => candidate.GroupKey, StringComparer.Ordinal)
.ToDictionary(
Expand Down Expand Up @@ -549,6 +560,8 @@ private static void AddModelSafetyGuards(ICollection<Candidate> candidates)
continue;
}

// Reuse the group's final enable value to synthesize a temporary
// off/bypass write; execution restores the original write last.
foreach (var enablingWrite in group.Where(static candidate => candidate.IsEnablingWrite).ToArray())
{
candidates.Add(enablingWrite.CreateGuard(nextSyntheticOrder++));
Expand Down
42 changes: 42 additions & 0 deletions src/WingSync.Core/Services/SyncCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,14 @@ public sealed class SyncCoordinator : IAsyncDisposable
private readonly ISyncObserver observer;
private readonly IClock clock;
private readonly WritePlanner planner = new();

// Lifecycle operations serialize separately from reconciliation: Start/Stop
// own lifecycleGate, while every snapshot or write transaction owns reconcileGate.
Comment thread
vanmobe marked this conversation as resolved.
private readonly SemaphoreSlim lifecycleGate = new(1, 1);
private readonly SemaphoreSlim reconcileGate = new(1, 1);

// These mirrors are observational only. desiredTargets is the authoritative
// target intent used to detect drift after a plan has been accepted.
private readonly ConcurrentDictionary<string, WingValue> sourceState =
new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, WingValue> targetState =
Expand All @@ -114,6 +120,9 @@ public sealed class SyncCoordinator : IAsyncDisposable
new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, EchoFingerprint> echoFingerprints =
new(StringComparer.Ordinal);

// Event callbacks cannot await the reconciliation gate. They stage changes
// under this short lock until a coherent snapshot-to-stream hand-off occurs.
private readonly object eventIntakeSync = new();
private readonly Dictionary<string, BufferedParameterEvent> deferredSourceEvents =
new(StringComparer.Ordinal);
Expand All @@ -129,6 +138,8 @@ public sealed class SyncCoordinator : IAsyncDisposable
private readonly object latencySync = new();
private readonly Queue<double> recentLatencies = new();

// A session cancellation source owns one complete connection epoch and all
// worker tasks started for it. Reconnect replaces the transports, not the workers.
private CancellationTokenSource? sessionCancellation;
private Channel<EngineWork>? workChannel;
private Task? workerTask;
Expand Down Expand Up @@ -244,6 +255,9 @@ public async Task StartAsync(
liveWritesArmed = !configuration.Safety.DryRun && confirmLiveInitialSync;
safetyPaused = false;
sessionCancellation = new CancellationTokenSource();

// Multiple session callbacks produce work, but one consumer performs
// planning and writes so console mutations remain strictly ordered.
workChannel = Channel.CreateBounded<EngineWork>(
new BoundedChannelOptions(QueueCapacity)
{
Expand All @@ -263,6 +277,8 @@ public async Task StartAsync(
{
ChangeStatus(SyncCoordinatorState.Connecting, "Identifying and connecting consoles.");

// Verify discovery identity before opening transports, then verify
// again through each connected console before any state is trusted.
await VerifyIdentitiesAsync(cancellationToken).ConfigureAwait(false);
await ConnectBothAsync(cancellationToken).ConfigureAwait(false);
await VerifyConnectedIdentitiesAsync(cancellationToken).ConfigureAwait(false);
Expand All @@ -283,6 +299,8 @@ public async Task StartAsync(
workerTask = ProcessWorkAsync(sessionCancellation.Token);
healthTask = HealthLoopAsync(sessionCancellation.Token);

// A live start without explicit approval stops at the exact fresh
// diff; event intake stays suspended so that preview cannot drift.
if (!configuration.Safety.DryRun && !liveWritesArmed)
{
SuspendEventIntake();
Expand Down Expand Up @@ -395,6 +413,9 @@ pendingInitialPlan is null ||
ChangeStatus(
SyncCoordinatorState.Snapshotting,
"After confirmation, reread both consoles.");

// Confirmation approves a diff, not merely an action. A changed diff
// must be shown again instead of inheriting the earlier approval.
var freshSnapshot = await BuildFreshPlanAsync(cancellationToken).ConfigureAwait(false);
var freshPlan = freshSnapshot.Plan;
RecordPlanningFindings(freshPlan);
Expand Down Expand Up @@ -783,6 +804,8 @@ private async Task<FreshPlanSnapshot> BuildFreshPlanAsync(CancellationToken canc
var wasInvalidated = false;
for (var attempt = 1; attempt <= SnapshotStabilityAttempts; attempt++)
{
// Sequence fences cover both console callbacks and transport changes.
// Only an attempt with identical fences can authorize live execution.
var before = GetEventSequences();
var plan = await ReadFreshPlanAttemptAsync(cancellationToken).ConfigureAwait(false);
var after = GetEventSequences();
Expand Down Expand Up @@ -821,6 +844,8 @@ private async Task<WritePlan> ReadFreshPlanAttemptAsync(CancellationToken cancel
readbackWaiters.Clear();
echoFingerprints.Clear();

// Read both consoles concurrently to minimize the interval in which an
// operator action could make the two snapshots describe different moments.
var sourceNodes = GetSnapshotNodes(currentConfiguration, sourceSide: true);
var targetNodes = GetSnapshotNodes(currentConfiguration, sourceSide: false);
var sourceSnapshotTask = ReadNodesAsync(
Expand Down Expand Up @@ -906,6 +931,8 @@ private async Task<IReadOnlyList<WingParameter>> ReadNodesAsync(
{
for (var attempt = 1; attempt <= 3; attempt++)
{
// Two scalar-certified passes must agree. Node snapshots alone are
// insufficient because some firmware exposes a stale local mirror.
var first = await ReadScalarCertifiedNodePassAsync(
session,
nodes,
Expand Down Expand Up @@ -944,6 +971,9 @@ private async Task<StableNodePass>
CancellationToken cancellationToken)
{
var candidates = new Dictionary<string, WingParameter>(StringComparer.Ordinal);

// Node reads discover the available leaves efficiently; exact scalar reads
// below certify every value that can enter the plan.
foreach (var node in nodes)
{
var snapshot = await session.SnapshotAsync(node.ToString(), cancellationToken)
Expand Down Expand Up @@ -1047,6 +1077,8 @@ private WritePlan FilterUnchangedWrites(WritePlan completePlan)
.Where(static group => group is not null)
.ToHashSet(StringComparer.Ordinal);

// Model changes can reset sibling leaves. Keep the entire guarded group,
// including values already equal on the target, so it is rebuilt coherently.
var retained = new List<PlannedWrite>();
foreach (var write in completePlan.Writes)
{
Expand Down Expand Up @@ -2411,6 +2443,8 @@ private async Task ProcessWorkAsync(CancellationToken cancellationToken)
continue;
}

// The short window absorbs bursts from one physical control move.
// Last source value wins; source work outranks target-drift work.
await clock.Delay(TimeSpan.FromMilliseconds(25), cancellationToken).ConfigureAwait(false);
var coalesced = new Dictionary<string, EngineWork>(StringComparer.Ordinal)
{
Expand Down Expand Up @@ -2455,6 +2489,8 @@ private async Task ProcessWorkAsync(CancellationToken cancellationToken)
IReadOnlyList<WingParameter>? expanded = null;
for (var attempt = 1; attempt <= 3; attempt++)
{
// A model event may imply changes to sibling settings.
// Expand and accept the group only while its event fence holds.
var expansionFence = GetEventSequences();
var candidate = await ExpandModelChangesAsync(valid, cancellationToken)
.ConfigureAwait(false);
Expand Down Expand Up @@ -4112,6 +4148,9 @@ private async Task ReconnectLoopAsync(string initialReason, CancellationToken ca
while (!cancellationToken.IsCancellationRequested && !stopping)
{
attempt++;

// Cap exponential backoff so recovery remains responsive without
// creating a tight retry loop during an extended outage.
var delay = TimeSpan.FromMilliseconds(
Math.Min(
MaximumReconnectDelay.TotalMilliseconds,
Expand All @@ -4132,6 +4171,9 @@ private async Task ReconnectLoopAsync(string initialReason, CancellationToken ca
await VerifyConnectedIdentitiesAsync(cancellationToken).ConfigureAwait(false);
await BeginCacheEpochAsync(cancellationToken).ConfigureAwait(false);
ChangeStatus(SyncCoordinatorState.Snapshotting, "After reconnect, reread both consoles.");

// Reconnection never resumes from cached state. It establishes
// a new epoch and rebuilds target intent from fresh console reads.
var freshSnapshot = await BuildFreshPlanAsync(cancellationToken).ConfigureAwait(false);
while (!IsSnapshotCurrent(freshSnapshot))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ public static bool TryParse(
var announcement = Encoding.ASCII.GetString(payload);
var fields = announcement.Split(',', StringSplitOptions.None);

// Trust the datagram source, not merely advertised text: requiring the
// two addresses to match prevents stale or forwarded announcements.
if (fields.Length != 6
|| !string.Equals(fields[0], "WING", StringComparison.Ordinal)
|| !TryParseCanonicalIPv4(fields[1], out var advertisedAddress)
Expand Down Expand Up @@ -92,6 +94,8 @@ private static bool TryParseCanonicalIPv4(
|| address.Equals(IPAddress.Broadcast)
|| IsIPv4Multicast(address))
{
// Canonical text rejects ambiguous shorthand such as leading-zero
// octets before the address is persisted as a console identity.
address = null;
return false;
}
Expand Down
Loading
Loading