diff --git a/Libraries/Esiur/Protocol/EpConnection.cs b/Libraries/Esiur/Protocol/EpConnection.cs
index cf8ef05..12fa7ce 100644
--- a/Libraries/Esiur/Protocol/EpConnection.cs
+++ b/Libraries/Esiur/Protocol/EpConnection.cs
@@ -3549,29 +3549,22 @@ public partial class EpConnection : NetworkConnection, IStore
foreach (var r in toBeRestored)
{
- var link = DC.ToBytes(r.ResourceLink);
-
Global.Log("EpConnection", LogType.Debug, "Restoreing " + r.ResourceLink);
try
{
- var id = (uint)await SendRequest(EpPacketRequest.GetResourceIdByLink, link);
-
-
- // remove from suspended.
+ // remove from suspended — Reattach(link, ...) below re-resolves
+ // and re-registers it under whatever id the server currently
+ // has (not necessarily the one we last knew — see Reattach's
+ // doc comment: the remote node's instance id is not permanent,
+ // only the link is), in a single round trip.
_suspendedResources.Remove(r.ResourceInstanceId);
- // id changed ?
- if (id != r.ResourceInstanceId)
- r.ResourceInstanceId = id;
-
- _neededResources[id] = r;
-
// Reattach using the last-known age so only properties modified while
// disconnected are transferred and merged, instead of re-fetching all.
- await Reattach(id, r.Instance.Age, r);
+ await Reattach(r.ResourceLink, r.Instance.Age, r);
- Global.Log("EpConnection", LogType.Debug, "Restored " + id);
+ Global.Log("EpConnection", LogType.Debug, "Restored " + r.ResourceInstanceId);
}
catch (AsyncException ex)
@@ -3720,6 +3713,14 @@ public partial class EpConnection : NetworkConnection, IStore
_suspendedResources[r.ResourceInstanceId] = x;
}
}
+ // Every entry just moved to _suspendedResources — clear the old map too,
+ // otherwise Reattach()'s "already attached" fast path
+ // (`_attachedResources[id]?.TryGetTarget(...)`) finds the still-live
+ // weak reference to the now-suspended resource and returns it
+ // immediately, short-circuiting before _Reattach() ever runs — so a
+ // reconnect never actually resets Status back to Attached/Published
+ // or resubscribes events.
+ _attachedResources.Clear();
if (Server != null)
{
diff --git a/Libraries/Esiur/Protocol/EpConnectionProtocol.cs b/Libraries/Esiur/Protocol/EpConnectionProtocol.cs
index 67b9980..50e25ac 100644
--- a/Libraries/Esiur/Protocol/EpConnectionProtocol.cs
+++ b/Libraries/Esiur/Protocol/EpConnectionProtocol.cs
@@ -1053,71 +1053,85 @@ partial class EpConnection
void EpRequestReattachResource(uint callback, PlainTdu tdu)
{
- // resourceId, index, value
+ // resourceLinkOrId (string link, or uint id — a link is resolved by
+ // query since a remote node's instance id is not permanent: the
+ // resource may have been cleared from memory and recreated under a
+ // different id while the peer was disconnected), age
var (valueOffset, valueSize, args) =
DataDeserializer.LimitedCountListParser(tdu.Data, tdu.PayloadOffset,
tdu.PayloadLength, Instance.Warehouse, 2);
- var resourceId = Convert.ToUInt32(args[0]);
-
var age = Convert.ToUInt64(args[1]);
- if (!TryBeginPeerAttachment(resourceId, out var exceptionCode, out var exceptionMessage))
+ void Resolved(IResource res)
{
- SendError(ErrorType.Management, callback, (ushort)exceptionCode, exceptionMessage);
- return;
- }
+ if (res == null)
+ {
+ Global.Log("EpConnection", LogType.Debug, "Not found (reattach) " + args[0]);
+ SendError(ErrorType.Management, callback, (ushort)ExceptionCode.ResourceNotFound);
+ return;
+ }
+
+ var resourceId = res.Instance.Id;
+
+ if (!TryBeginPeerAttachment(resourceId, out var exceptionCode, out var exceptionMessage))
+ {
+ SendError(ErrorType.Management, callback, (ushort)exceptionCode, exceptionMessage);
+ return;
+ }
- Instance.Warehouse.GetById(resourceId).Then((res) =>
- {
try
{
- if (res != null)
- {
- if (!TryApplyManagers(
- null,
- res,
- ActionType.Attach,
- callback,
- ErrorType.Management,
- ExceptionCode.AttachDenied,
- out _))
- return;
+ if (!TryApplyManagers(
+ null,
+ res,
+ ActionType.Attach,
+ callback,
+ ErrorType.Management,
+ ExceptionCode.AttachDenied,
+ out _))
+ return;
- var r = res as IResource;
+ var r = res;
- // unsubscribe
- Unsubscribe(r);
+ // unsubscribe
+ Unsubscribe(r);
+ // reply ok — the resolved id comes first so the caller can
+ // detect and apply an id change (link-based reattach, or the
+ // remote node recreated the resource with a new id).
+ SendReply(EpPacketReply.Completed, callback,
+ r.Instance.Id,
+ r.Instance.Definition.Id,
+ r.Instance.Age,
+ r.Instance.Link,
+ r.Instance.Hops,
+ r.Instance.SerializeAfter(age));
- // reply ok
- SendReply(EpPacketReply.Completed, callback,
- r.Instance.Definition.Id,
- r.Instance.Age,
- r.Instance.Link,
- r.Instance.Hops,
- r.Instance.SerializeAfter(age));
-
-
- // subscribe
- Subscribe(r);
- }
- else
- {
- // reply failed
- Global.Log("EpConnection", LogType.Debug, "Not found " + resourceId);
- SendError(ErrorType.Management, callback, (ushort)ExceptionCode.ResourceNotFound);
- }
+ // subscribe
+ Subscribe(r);
}
finally
{
EndPeerAttachment(resourceId);
}
- }).Error(ex =>
- {
- EndPeerAttachment(resourceId);
+ }
+
+ void Failed(Exception ex) =>
SendError(ErrorType.Management, callback, (ushort)ExceptionCode.GeneralFailure, ex.Message);
- });
+
+ if (args[0] is string link)
+ {
+ if (Server?.EntryPoint != null)
+ Server.EntryPoint.Query(link, this).Then(Resolved).Error(Failed);
+ else
+ Instance.Warehouse.Query(link).Then(Resolved).Error(Failed);
+ }
+ else
+ {
+ var resourceId = Convert.ToUInt32(args[0]);
+ Instance.Warehouse.GetById(resourceId).Then(Resolved).Error(Failed);
+ }
}
void EpRequestDetachResource(uint callback, PlainTdu tdu)
@@ -1496,7 +1510,11 @@ partial class EpConnection
out _))
return;
- SendReply(EpPacketReply.Completed, callback, r);
+ // Only the id — this opcode resolves a link to an identity,
+ // it does not attach. (A resource reference in a reply would
+ // auto-attach on decode, which is not what a bare id lookup
+ // should do — see Get(string) for the real attach step.)
+ SendReply(EpPacketReply.Completed, callback, r.Instance.Id);
}
};
@@ -2742,36 +2760,24 @@ partial class EpConnection
/// Resource
public AsyncReply Get(string path)
{
-
var rt = new AsyncReply();
- var req = SendRequest(EpPacketRequest.GetResourceIdByLink, path);
-
-
- req.Then(result =>
+ // Two steps, deliberately: resolve the link to its (not permanent —
+ // the remote node may recreate the resource under a different id)
+ // current instance id, then attach by that id. GetResourceIdByLink
+ // returns a bare id, not a resource — it must not have the side
+ // effect of attaching anything on its own.
+ SendRequest(EpPacketRequest.GetResourceIdByLink, path).Then(result =>
+ {
+ var id = Convert.ToUInt32(result);
+ FetchResource(id, null).Then(resource =>
{
- // The resource is being handed to the application: remember it and publish its graph
- // once all reachable dependencies have attached.
- if (result is EpResource resource)
- TrackDeliveredRoot(resource);
-
- rt.Trigger(result);
+ // The resource is being handed to the application: remember it and publish its
+ // graph once all reachable dependencies have attached.
+ TrackDeliveredRoot(resource);
+ rt.Trigger(resource);
}).Error(ex => rt.TriggerError(ex));
-
-
- //Query(path).Then(ar =>
- //{
-
- // //if (filter != null)
- // // ar = ar?.Where(filter).ToArray();
-
- // // MISSING: should dispatch the unused resources.
- // if (ar?.Length > 0)
- // rt.Trigger(ar[0]);
- // else
- // rt.Trigger(null);
- //}).Error(ex => rt.TriggerError(ex));
-
+ }).Error(ex => rt.TriggerError(ex));
return rt;
}
@@ -3265,62 +3271,123 @@ partial class EpConnection
/// merged into the existing instance instead of re-fetching everything. Falls back to a full
/// if there is no prior state to merge into.
///
- public AsyncReply Reattach(uint id, ulong age, EpResource resource)
+ ///
+ /// A link or a id. Prefer the link: the remote node's
+ /// instance id is not permanent (the resource may have been cleared from memory and recreated
+ /// under a different id while disconnected), but the link is — passing it resolves the current
+ /// id and reattaches in a single round trip instead of a separate GetResourceIdByLink first.
+ ///
+ public AsyncReply Reattach(object resourceLinkOrId, ulong age, EpResource resource)
{
- EpResource attachedResource = null;
- _attachedResources[id]?.TryGetTarget(out attachedResource);
- if (attachedResource != null)
- return new AsyncReply(attachedResource);
+ // The already-attached / already-in-flight fast paths only apply when we
+ // already know a specific id — a link's current id isn't known until the
+ // server resolves it. (Concurrent duplicate reattach-by-link calls for the
+ // same resource aren't deduplicated the way FetchResource dedupes concurrent
+ // attach-by-id calls; the sole caller today — the reconnect loop — only ever
+ // calls this once per resource per reconnect, so this is a deliberate
+ // simplification, not an oversight.)
+ if (resourceLinkOrId is uint knownId)
+ {
+ EpResource attachedResource = null;
+ _attachedResources[knownId]?.TryGetTarget(out attachedResource);
+ if (attachedResource != null)
+ return new AsyncReply(attachedResource);
- var existing = _resourceRequests[id];
- if (existing != null)
- return existing.Reply;
+ var existing = _resourceRequests[knownId];
+ if (existing != null)
+ return existing.Reply;
+ }
var reply = new AsyncReply();
- var sequence = new uint[] { id };
- if (!TryReserveResourceAttachment(id, reply, sequence, out var alternative))
- return alternative;
-
ResourceAttachRequestCount++;
- SendRequest(EpPacketRequest.ReattachResource, id, age).Then(result =>
+ SendRequest(EpPacketRequest.ReattachResource, resourceLinkOrId, age).Then(result =>
{
if (result == null)
{
- _resourceRequests.Remove(id);
reply.TriggerError(new AsyncException(ErrorType.Management,
(ushort)ExceptionCode.ResourceNotFound, "Null response"));
return;
}
- // typeId, age, link, hops, delta(index -> PropertyValue)
+ // resolvedId, typeId, age, link, hops, delta(index -> PropertyValue)
var args = (object[])result;
- var deltaData = (byte[])args[4];
+ var resolvedId = Convert.ToUInt32(args[0]);
+ var deltaData = (byte[])args[5];
+ var sequence = new uint[] { resolvedId };
+ var oldId = resource.ResourceInstanceId;
DataDeserializer.PropertyValueMapParserAsync(deltaData, 0, (uint)deltaData.Length, this, sequence)
.Then(delta =>
{
+ if (resolvedId != oldId)
+ {
+ // Re-key our own tracking to the id the server actually
+ // resolved the link to — see the id-is-not-permanent note above.
+ resource.ResourceInstanceId = resolvedId;
+
+ // Only evict oldId's entries if they still point to THIS
+ // resource. If the remote node reused oldId for a different
+ // resource (freed, then reassigned) and something else
+ // concurrently attached it while this reattach was in
+ // flight, a blind Remove(oldId) would wrongly evict that
+ // unrelated resource's own valid tracking.
+ var oldAttachedRef = _attachedResources[oldId];
+ if (oldAttachedRef != null
+ && oldAttachedRef.TryGetTarget(out var oldAttached)
+ && ReferenceEquals(oldAttached, resource))
+ _attachedResources.Remove(oldId);
+
+ if (ReferenceEquals(_neededResources[oldId], resource))
+ _neededResources.Remove(oldId);
+
+ // Safe unconditionally: this dictionary just marks "still
+ // suspended, needs restoring", which is false either way now
+ // that we're mid-reattach — no risk of evicting something
+ // unrelated's meaningful state. (Already removed by the
+ // reconnect loop's own pre-cleanup in the common case.)
+ _suspendedResources.Remove(oldId);
+
+ // Reattach() never reserves a _resourceRequests entry before
+ // sending the request (see the fast-path comment above), so
+ // there is nothing of ours to remove here — left out
+ // entirely rather than risk canceling an unrelated in-flight
+ // request that happens to share the reused oldId.
+ }
+
if (!resource._Reattach(delta))
{
// No prior state to merge into — perform a full attach instead.
- _resourceRequests.Remove(id);
- FetchResource(id, null).Then(r => reply.Trigger(r)).Error(ex => reply.TriggerError(ex));
+ _resourceRequests.Remove(resolvedId);
+ FetchResource(resolvedId, null).Then(r => reply.Trigger(r)).Error(ex => reply.TriggerError(ex));
return;
}
- _resourceRequests.Remove(id);
- _neededResources.Remove(id);
- _attachedResources[id] = new WeakReference(resource);
- ClearResourceFetchNode(id);
+ _resourceRequests.Remove(resolvedId);
+ _neededResources.Remove(resolvedId);
+ _attachedResources[resolvedId] = new WeakReference(resource);
+ ClearResourceFetchNode(resolvedId);
+ // TryPublishDeliveredRoots() alone isn't enough here: it only
+ // re-checks resources tracked in _deliveredRoots, which a
+ // resource that attached cleanly on its *first* attempt
+ // (the common case) never enters — see TrackDeliveredRoot's
+ // early return. Without this, a reattached resource stays
+ // stuck at ResourceStatus.Attached forever after a
+ // reconnect, since nothing else ever calls Publish() on it.
+ PublishGraph(resource);
TryPublishDeliveredRoots();
reply.Trigger(resource);
})
- .Error(ex => { _resourceRequests.Remove(id); ClearResourceFetchNode(id); reply.TriggerError(ex); });
- }).Error(ex =>
- {
- _resourceRequests.Remove(id);
- ClearResourceFetchNode(id);
- reply.TriggerError(ex);
- });
+ .Error(ex =>
+ {
+ _resourceRequests.Remove(resolvedId);
+ ClearResourceFetchNode(resolvedId);
+ reply.TriggerError(ex);
+ });
+ }).Error(ex => reply.TriggerError(ex));
+ // No _resourceRequests/fetch-node cleanup needed here: unlike
+ // FetchResource's id-based path, this method never reserves an
+ // entry before the request is sent (see the fast-path comment
+ // above), so there is nothing to unwind if the request itself fails.
return reply;
}
diff --git a/Libraries/Esiur/Protocol/EpResource.cs b/Libraries/Esiur/Protocol/EpResource.cs
index fb350c1..4e61d11 100644
--- a/Libraries/Esiur/Protocol/EpResource.cs
+++ b/Libraries/Esiur/Protocol/EpResource.cs
@@ -80,6 +80,18 @@ public class EpResource : DynamicObject, IResource, INotifyPropertyChanged, IDyn
EpResourceEvent[] _events;
+ // `On`/`Off` listener bags, keyed by property/event index — separate from
+ // the single-slot `_events[]` dynamic-member mechanism (`resource.Foo +=
+ // handler`) above, which only ever holds one delegate per event.
+ readonly Dictionary>> _propertyListeners = new();
+ readonly Dictionary>> _eventListeners = new();
+ // Events we believe the server currently has us subscribed to — checked
+ // before sending a Subscribe/Unsubscribe request, since the server errors
+ // (AlreadyListened/AlreadyUnsubscribed) on a redundant one.
+ readonly HashSet _subscribedEvents = new();
+ // Event indices with a subscription-reconciliation loop currently running.
+ readonly HashSet _reconciling = new();
+
///
@@ -260,6 +272,12 @@ public class EpResource : DynamicObject, IResource, INotifyPropertyChanged, IDyn
}
_status = Resource.ResourceStatus.Attached;
+ // A reattach can follow an unexpected disconnect + automatic
+ // reconnect: the server-side subscription state keyed to the old,
+ // now-dead connection is gone, but our local listeners (On()/+=)
+ // are untouched, so without this we'd wrongly believe we're still
+ // subscribed and never resend the wire Subscribe request.
+ ReconcileAllSubscriptions();
return true;
}
@@ -269,6 +287,7 @@ public class EpResource : DynamicObject, IResource, INotifyPropertyChanged, IDyn
var et = Instance.Definition.GetEventDefByIndex(index);
_events[index]?.Invoke(this, args);
Instance.EmitResourceEvent(et, args);
+ DispatchListeners(_eventListeners, index, args);
}
public AsyncReply _Invoke(byte index, object args)
@@ -371,6 +390,164 @@ public class EpResource : DynamicObject, IResource, INotifyPropertyChanged, IDyn
return Unsubscribe(et);
}
+ ///
+ /// Listen for a property change (On(":propName", cb)) or an
+ /// exported event (On("eventName", cb)). For events where the
+ /// TypeDef marks (i.e. not
+ /// [AutoDelivery]), the first listener triggers a Subscribe
+ /// request and the last triggers Unsubscribe —
+ /// ref-counted by listener count, so redundant wire requests aren't sent
+ /// for a second/third listener on the same already-subscribed event.
+ ///
+ public EpResource On(string name, Action