feat(cordis): 完成阶段5访问介导与诊断
This commit is contained in:
@@ -74,6 +74,11 @@ mono_crash.*
|
||||
*.unitypackage.meta
|
||||
*.app
|
||||
|
||||
# Windows Git matches the macOS bundle pattern case-insensitively. Keep the
|
||||
# namespace-suffixed Unity package in source control.
|
||||
!Assets/Modules/ShrinkDataSaver.Integration.App/
|
||||
!Assets/Modules/ShrinkDataSaver.Integration.App/**
|
||||
|
||||
# Crashlytics generated file
|
||||
crashlytics-build.properties
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
@@ -95,5 +96,56 @@ namespace ShrinkCommand.Integration.Network.Tests
|
||||
Assert.IsFalse(_runtime.TryGetRaw<object>(_runtime.RootContext,
|
||||
ShrinkDataSaverAppComponent.ServiceKey, out _));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DataSaverIntercept_ReadOnlyViewAllowsReadsAndDeniesWriterCapability()
|
||||
{
|
||||
var provider = _runtime.Use(new ShrinkDataSaverAppComponent());
|
||||
Assert.AreEqual(ShrinkFiberState.Active, provider.State);
|
||||
|
||||
var intercept = new Dictionary<string, IReadOnlyDictionary<string, object?>>
|
||||
{
|
||||
[ShrinkDataSaverAppComponent.ServiceKey] = new Dictionary<string, object?>
|
||||
{
|
||||
[ShrinkDataSaverAccess.MetadataKey] = ShrinkDataSaverAccess.ReadOnly
|
||||
}
|
||||
};
|
||||
var consumerComponent = new DataSaverAccessConsumer();
|
||||
var consumer = _runtime.Use(consumerComponent, intercept: intercept);
|
||||
|
||||
Assert.AreEqual(ShrinkFiberState.Active, consumer.State);
|
||||
var reader = consumerComponent.GetReader();
|
||||
Assert.IsNotNull(reader);
|
||||
Assert.IsFalse(reader is IShrinkDataSaverWriter,
|
||||
"只读上下文取得的对象不应再暴露写接口");
|
||||
Assert.Throws<ShrinkCoeffectAccessDeniedException>(() => consumerComponent.GetWriter());
|
||||
|
||||
Assert.IsTrue(_runtime.TryGetRaw<ShrinkDataSaverService>(_runtime.RootContext,
|
||||
ShrinkDataSaverAppComponent.ServiceKey, out _),
|
||||
"编排层 raw read 保留原始服务;intercept 只介导组件访问");
|
||||
}
|
||||
|
||||
private sealed class DataSaverAccessConsumer : IShrinkComponent
|
||||
{
|
||||
private ShrinkCtx? _ctx;
|
||||
|
||||
public string Name => "test.datasaver.read-only-consumer";
|
||||
public IReadOnlyList<string> Inject { get; } = new[] { ShrinkDataSaverAppComponent.ServiceKey };
|
||||
public IReadOnlyList<string> Provide => Array.Empty<string>();
|
||||
|
||||
public UniTask ApplyAsync(ShrinkCtx ctx, object? config)
|
||||
{
|
||||
_ctx = ctx;
|
||||
return UniTask.CompletedTask;
|
||||
}
|
||||
|
||||
public IShrinkDataSaverReader GetReader() => Context.Get<IShrinkDataSaverReader>(
|
||||
ShrinkDataSaverAppComponent.ServiceKey);
|
||||
|
||||
public IShrinkDataSaverWriter GetWriter() => Context.Get<IShrinkDataSaverWriter>(
|
||||
ShrinkDataSaverAppComponent.ServiceKey);
|
||||
|
||||
private ShrinkCtx Context => _ctx ?? throw new InvalidOperationException("Consumer is not active.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ShrinkContext.Core
|
||||
|
||||
《Cordis: A Programming Paradigm for Spatiotemporal Composability》核心机制的 Unity/C# 原型(改造方案见仓库根 `CORDIS_MIGRATION.md` 的阶段 0 产物)。
|
||||
《Cordis: A Programming Paradigm for Spatiotemporal Composability》核心机制的 Unity/C# 实现(改造方案与当前边界见仓库根 `CORDIS_MIGRATION.md`)。
|
||||
|
||||
## 定位
|
||||
|
||||
@@ -9,16 +9,17 @@
|
||||
- **可逆效应**(时间可组合性,论文 3.1 / 算法 1):`ctx.Effect / EffectAsync`,前向执行时产出逆操作,运行时按 LIFO 累积,卸载即回滚;
|
||||
- **响应式余效应**(空间可组合性,论文 3.2 / 算法 2、3):`ctx.Set / Get`,依赖键的安装/撤回自动通知依赖者,组件在依赖全部可用时激活、失效时停用、换提供者时重载;
|
||||
- **纤程生命周期**(论文 4 / 算法 4、5):`runtime.Use(component)` 实例化组件为纤程,惯性状态机 `Loading → Active → Unloading → Inactive`,卸载先等待依赖者排空再回滚自身效应,父卸载级联子卸载。
|
||||
- **声明式协调与访问介导**:加载器以事务更新期望组合,失败时恢复旧组合;`isolate / intercept` 控制解析域与服务视图,组件供给受 `Provide` 声明约束。
|
||||
|
||||
## 目录结构
|
||||
|
||||
| 目录 | 说明 |
|
||||
|---|---|
|
||||
| `Runtime/Context/` | `ShrinkCtx`(一等上下文、隔离派生、介导访问)与 `ShrinkEffectHandle`(算法 1) |
|
||||
| `Runtime/Coeffects/` | 绑定与异常类型(供给冲突、未声明/未激活访问) |
|
||||
| `Runtime/Coeffects/` | 绑定、强类型版本键、访问策略与异常类型 |
|
||||
| `Runtime/Fibers/` | `IShrinkComponent`(inject/provide/apply)、`ShrinkFiber`、分步效应接口 |
|
||||
| `Runtime/Loader/` | 声明式加载器(论文 5.2 原型子集):`ShrinkLoaderEntry` / `ShrinkComponentCatalog` / `ShrinkContextLoader` 增量协调 |
|
||||
| `Runtime/` | `ShrinkContextRuntime`(注册表 + 算法 2-5)、`ShrinkContextDefaults`(Domain Reload 重置) |
|
||||
| `Runtime/Loader/` | 声明式加载器:`ShrinkLoaderEntry` / `ShrinkComponentCatalog` / `ShrinkContextLoader` 增量协调、事务恢复与条目诊断 |
|
||||
| `Runtime/` | `ShrinkContextRuntime`(注册表 + 算法 2-5 + notify 倒排索引)、诊断快照与 Domain Reload 重置 |
|
||||
| `Tests/` | EditMode 测试:对照论文定理 7/16/20/63/64 与算法 1/2/3/4/5/6 的可观察行为 + 加载器协调行为 |
|
||||
|
||||
## 论文 → API 对照
|
||||
@@ -28,12 +29,13 @@
|
||||
| `ctx.effect(callback)` → dispose | `ShrinkCtx.Effect / EffectAsync` → `ShrinkEffectHandle.DisposeAsync`(armed 单次执行,等待进行中前向) |
|
||||
| `ctx.get/set`(算法 2) | `ShrinkCtx.Set<T>`(可逆,安装+通知 / 撤回+通知) |
|
||||
| `ctx.isolate(key, realm)` | `ShrinkCtx.Isolate`(派生子上下文,丢弃即恢复) |
|
||||
| 代理介导访问(算法 6) | `ShrinkCtx.Get<T>/TryGet`(沿纤程链解析已提交视图;未声明/未激活访问抛异常) |
|
||||
| `ctx.intercept(key, metadata)` | `ShrinkCtx.Intercept`(派生访问元数据;provider 通过 `IShrinkCoeffectAccessPolicy` 返回受限视图或拒绝访问) |
|
||||
| 代理介导访问(算法 6) | `ShrinkCtx.Get<T>/TryGet`(沿纤程链解析已提交视图;未声明/未激活/策略拒绝分别抛出不同异常) |
|
||||
| `ctx.use(component)`(算法 4) | `ShrinkContextRuntime.Use`(实例化是父上下文的可逆效应 → 级联卸载;支持条目级 isolate 叠加) |
|
||||
| 声明式配置 + 协调(论文 5.2) | `ShrinkContextLoader.ApplyAsync`(条目消失→退役;disabled→卸载;组件/配置变化→重建;无变化→幂等;依赖缺失→条目保持被管理并等待) |
|
||||
| 声明式配置 + 协调(论文 5.2) | `ShrinkContextLoader.ApplyAsync`(预校验期望组合;条目消失→退役;组件/配置变化→重建;intercept 变化→原位更新;失败→恢复旧组合) |
|
||||
| refresh/reload/unload(算法 5) | `RunTransitionAsync`(惯性链式转换;卸载先 drain 依赖者) |
|
||||
| notify(算法 3) | `ShrinkContextRuntime.Notify`(realm 一致的依赖者刷新) |
|
||||
| target = 提供者 uid 集合 | `ShrinkFiber.Target`(uid 永不复用:同值不同提供者也触发重载) |
|
||||
| notify(算法 3) | `ShrinkContextRuntime.Notify`(`key → inject fibers` 倒排索引选候选,再按 realm 过滤) |
|
||||
| target = 按 inject 顺序解析的提供者 uid | `ShrinkFiber.Target`(保留键顺序与重复依赖;uid 永不复用,因此同值换提供者也会重载) |
|
||||
| `𝔈iterΓ` 分步效应 | `IShrinkIterativeComponent.ApplySteps`(自定义 UniTask 步进器,步进边界守卫 → 部分回滚;不用 IAsyncEnumerable,规避 Mono ValueTask 迭代器的非内联调度) |
|
||||
| fiber.committed 提交视图 | `ShrinkFiber.Committed`(装载前提交,拆除完才丢弃) |
|
||||
|
||||
@@ -41,6 +43,7 @@
|
||||
|
||||
- 定理 7(恢复初始上下文)/ 定理 16(LIFO)/ 选择性撤销 / armed 幂等 / dispose 等待进行中前向
|
||||
- set 可逆性、介导访问纪律(UNDECLARED / INACTIVE)、隔离域派生与丢弃恢复
|
||||
- 未声明供给回滚、访问策略拒绝、intercept 原位更新与只读服务视图
|
||||
- 依赖驱动激活、缺依赖静默非活动、晚到提供者激活
|
||||
- 退役次序:依赖者先于提供者逆操作排空(drain-before-inverse)
|
||||
- 提供者替换重载(含同值替换,uid 语义)
|
||||
@@ -48,14 +51,16 @@
|
||||
- 惯性:装载中途退役永不 Active、链式卸载(论文 4.3.3)
|
||||
- 分步效应部分回滚(定理 64)
|
||||
- apply 失败的部分效应回滚(L-Raise)
|
||||
- 加载器失败恢复旧组合、跨 realm 替换隔离、notify 倒排索引候选计数与运行时诊断
|
||||
- Domain Reload 重置
|
||||
|
||||
## 明确不在本原型内(后续阶段)
|
||||
## 当前边界
|
||||
|
||||
- 声明式配置树加载器与增量协调(阶段 2)
|
||||
- 热模块替换 / ModFramework 合流(阶段 4,受 Unity 程序集不可卸载约束,回滚单位是纤程实例)
|
||||
- 拦截(intercept)与细粒度访问控制(阶段 5)
|
||||
- 主线程泵与 MonoBehaviour 宿主、notify 的 key→fibers 倒排索引(性能)
|
||||
- `intercept` 是经 `ShrinkCtx.Get` 的能力介导,不是对不可信 DLL 的内存、反射、文件或网络沙箱。
|
||||
- loader 的 isolate 变化仍通过重建条目生效;尚未提供运行中 fiber 的原位 realm 迁移。
|
||||
- 强类型版本键是新增契约,现有字符串键不会在阶段 5 被一次性重写。
|
||||
- 尚未提供 ScriptableObject/JSON 配置资产、Editor 调试面和正式容量基准。
|
||||
- 主线程宿主与 Unity PlayerLoop 调度仍由上层应用负责。
|
||||
- 跨进程/独立服务器上下文
|
||||
|
||||
## 约定
|
||||
|
||||
@@ -1,21 +1,92 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ShrinkContext
|
||||
{
|
||||
/// <summary>共享存储中的一条依赖绑定:键解析到 realm 后对应的值与提供者纤程。</summary>
|
||||
public sealed class ShrinkBinding
|
||||
{
|
||||
public ShrinkBinding(object? value, ShrinkFiber? provider, string key)
|
||||
public ShrinkBinding(object? value, ShrinkFiber? provider, string key,
|
||||
IShrinkCoeffectAccessPolicy? accessPolicy = null)
|
||||
{
|
||||
Value = value;
|
||||
Provider = provider;
|
||||
Key = key;
|
||||
AccessPolicy = accessPolicy;
|
||||
}
|
||||
|
||||
public object? Value { get; }
|
||||
public ShrinkFiber? Provider { get; }
|
||||
public string Key { get; }
|
||||
public IShrinkCoeffectAccessPolicy? AccessPolicy { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 带命名空间与主版本的类型化余效应键。现有字符串声明可继续使用 <see cref="Id"/>,
|
||||
/// Set/Get 重载则在调用点固定值类型,逐步收口字符串键的碰撞与接口漂移。
|
||||
/// </summary>
|
||||
public sealed class ShrinkKey<T>
|
||||
{
|
||||
public ShrinkKey(string packageName, string name, int majorVersion = 1)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(packageName))
|
||||
throw new ArgumentException("Package name must not be empty.", nameof(packageName));
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ArgumentException("Key name must not be empty.", nameof(name));
|
||||
if (majorVersion <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(majorVersion), "Major version must be positive.");
|
||||
|
||||
PackageName = packageName.Trim();
|
||||
Name = name.Trim();
|
||||
MajorVersion = majorVersion;
|
||||
Id = $"{PackageName}/{Name}@v{MajorVersion}";
|
||||
}
|
||||
|
||||
public string PackageName { get; }
|
||||
public string Name { get; }
|
||||
public int MajorVersion { get; }
|
||||
public string Id { get; }
|
||||
public override string ToString() => Id;
|
||||
}
|
||||
|
||||
/// <summary>依赖值被读取时的访问上下文;metadata 来自访问方上下文上的 intercept 合并结果。</summary>
|
||||
public sealed class ShrinkCoeffectAccessContext
|
||||
{
|
||||
internal ShrinkCoeffectAccessContext(string key, Type requestedType, ShrinkFiber? consumer,
|
||||
IReadOnlyDictionary<string, object?> metadata)
|
||||
{
|
||||
Key = key;
|
||||
RequestedType = requestedType;
|
||||
Consumer = consumer;
|
||||
Metadata = metadata;
|
||||
}
|
||||
|
||||
public string Key { get; }
|
||||
public Type RequestedType { get; }
|
||||
public ShrinkFiber? Consumer { get; }
|
||||
public IReadOnlyDictionary<string, object?> Metadata { get; }
|
||||
|
||||
public bool TryGetMetadata<T>(string name, out T value)
|
||||
{
|
||||
if (Metadata.TryGetValue(name, out var raw) && raw is T typed)
|
||||
{
|
||||
value = typed;
|
||||
return true;
|
||||
}
|
||||
|
||||
value = default!;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// provider 侧访问策略。它可以依据 intercept metadata 返回降权包装或拒绝访问,
|
||||
/// 但不会改变依赖满足关系,也不会构成不可信代码沙箱。
|
||||
/// </summary>
|
||||
public interface IShrinkCoeffectAccessPolicy
|
||||
{
|
||||
object? Resolve(ShrinkCoeffectAccessContext context, object? value);
|
||||
}
|
||||
|
||||
/// <summary>访问了纤程链上没有任何一方声明的依赖键(论文算法 6 的 UNDECLARED_ACCESS)。</summary>
|
||||
@@ -42,6 +113,32 @@ namespace ShrinkContext
|
||||
public string Key { get; }
|
||||
}
|
||||
|
||||
/// <summary>组件写入了未在 Provide 中声明的键。</summary>
|
||||
public sealed class ShrinkUndeclaredSupplyException : Exception
|
||||
{
|
||||
public ShrinkUndeclaredSupplyException(string key, string fiberName)
|
||||
: base($"Undeclared coeffect supply: '{key}' is not listed in fiber '{fiberName}' Provide keys.")
|
||||
{
|
||||
Key = key;
|
||||
FiberName = fiberName;
|
||||
}
|
||||
|
||||
public string Key { get; }
|
||||
public string FiberName { get; }
|
||||
}
|
||||
|
||||
/// <summary>intercept 访问策略拒绝了本次依赖读取。</summary>
|
||||
public sealed class ShrinkCoeffectAccessDeniedException : Exception
|
||||
{
|
||||
public ShrinkCoeffectAccessDeniedException(string key, string message)
|
||||
: base($"Coeffect access denied for '{key}': {message}")
|
||||
{
|
||||
Key = key;
|
||||
}
|
||||
|
||||
public string Key { get; }
|
||||
}
|
||||
|
||||
/// <summary>两个活跃纤程试图供给同一个 realm(论文定义 45 的供给不相交约束)。</summary>
|
||||
public sealed class ShrinkSupplyConflictException : Exception
|
||||
{
|
||||
|
||||
@@ -15,14 +15,17 @@ namespace ShrinkContext
|
||||
{
|
||||
private readonly List<ShrinkEffectHandle> _effects = new();
|
||||
private readonly Dictionary<string, string>? _isolateOverlay;
|
||||
private Dictionary<string, IReadOnlyDictionary<string, object?>>? _interceptOverlay;
|
||||
|
||||
internal ShrinkCtx(ShrinkContextRuntime runtime, ShrinkCtx? parent, ShrinkFiber? ownerFiber,
|
||||
Dictionary<string, string>? isolateOverlay)
|
||||
Dictionary<string, string>? isolateOverlay,
|
||||
IReadOnlyDictionary<string, IReadOnlyDictionary<string, object?>>? interceptOverlay = null)
|
||||
{
|
||||
Runtime = runtime;
|
||||
Parent = parent;
|
||||
OwnerFiber = ownerFiber;
|
||||
_isolateOverlay = isolateOverlay;
|
||||
ReplaceIntercept(interceptOverlay);
|
||||
}
|
||||
|
||||
public ShrinkContextRuntime Runtime { get; }
|
||||
@@ -106,7 +109,17 @@ namespace ShrinkContext
|
||||
/// 提供依赖(set 是可逆效应):立即安装绑定并通知依赖者;
|
||||
/// 逆操作撤回绑定并再次通知。句柄同时挂在当前上下文上,随上下文卸载自动撤回。
|
||||
/// </summary>
|
||||
public ShrinkEffectHandle Set<T>(string key, T value) => Runtime.SetBinding(this, key, value);
|
||||
public ShrinkEffectHandle Set<T>(string key, T value) => Runtime.SetBinding(this, key, value, null);
|
||||
|
||||
/// <summary>提供带访问策略的依赖;策略只在介导 Get 时运行,原始编排查找不会运行策略。</summary>
|
||||
public ShrinkEffectHandle Set<T>(string key, T value, IShrinkCoeffectAccessPolicy accessPolicy) =>
|
||||
Runtime.SetBinding(this, key, value, accessPolicy ?? throw new ArgumentNullException(nameof(accessPolicy)));
|
||||
|
||||
public ShrinkEffectHandle Set<T>(ShrinkKey<T> key, T value) =>
|
||||
Set((key ?? throw new ArgumentNullException(nameof(key))).Id, value);
|
||||
|
||||
public ShrinkEffectHandle Set<T>(ShrinkKey<T> key, T value, IShrinkCoeffectAccessPolicy accessPolicy) =>
|
||||
Set((key ?? throw new ArgumentNullException(nameof(key))).Id, value, accessPolicy);
|
||||
|
||||
/// <summary>
|
||||
/// 代理介导的依赖访问(论文算法 6):沿纤程链解析已提交视图;
|
||||
@@ -119,7 +132,7 @@ namespace ShrinkContext
|
||||
while (fiber != null)
|
||||
{
|
||||
if (fiber.Committed != null && fiber.Committed.TryGetValue(key, out var binding))
|
||||
return (T)binding.Value!;
|
||||
return Runtime.ResolveAccess<T>(this, key, binding);
|
||||
if (fiber.InjectSet.Contains(key))
|
||||
throw new ShrinkInactiveAccessException(key, fiber.Name);
|
||||
fiber = fiber.Parent;
|
||||
@@ -128,6 +141,9 @@ namespace ShrinkContext
|
||||
throw new ShrinkUndeclaredAccessException(key);
|
||||
}
|
||||
|
||||
public T Get<T>(ShrinkKey<T> key) => Get<T>(
|
||||
(key ?? throw new ArgumentNullException(nameof(key))).Id);
|
||||
|
||||
/// <summary>与 <see cref="Get{T}"/> 相同的介导规则;未激活返回 false 而不抛异常。</summary>
|
||||
public bool TryGet<T>(string key, out T value)
|
||||
{
|
||||
@@ -136,7 +152,7 @@ namespace ShrinkContext
|
||||
{
|
||||
if (fiber.Committed != null && fiber.Committed.TryGetValue(key, out var binding))
|
||||
{
|
||||
value = (T)binding.Value!;
|
||||
value = Runtime.ResolveAccess<T>(this, key, binding);
|
||||
return true;
|
||||
}
|
||||
if (fiber.InjectSet.Contains(key))
|
||||
@@ -148,6 +164,9 @@ namespace ShrinkContext
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGet<T>(ShrinkKey<T> key, out T value) => TryGet(
|
||||
(key ?? throw new ArgumentNullException(nameof(key))).Id, out value);
|
||||
|
||||
/// <summary>
|
||||
/// 派生隔离子上下文(论文定义 28/29 的派生实现):
|
||||
/// 覆盖 key 的 realm 解析;父上下文不受影响;丢弃子上下文即隐式恢复。
|
||||
@@ -162,6 +181,33 @@ namespace ShrinkContext
|
||||
return new ShrinkCtx(Runtime, this, OwnerFiber, new Dictionary<string, string> { [key] = realm });
|
||||
}
|
||||
|
||||
public ShrinkCtx Isolate<T>(ShrinkKey<T> key, string realm) => Isolate(
|
||||
(key ?? throw new ArgumentNullException(nameof(key))).Id, realm);
|
||||
|
||||
/// <summary>
|
||||
/// 派生带拦截元数据的子上下文。子级同名 metadata 覆盖父级;丢弃子上下文即恢复。
|
||||
/// 该操作不改变 realm、provider 或 target,因此不会触发 fiber 重载。
|
||||
/// </summary>
|
||||
public ShrinkCtx Intercept(string key, IReadOnlyDictionary<string, object?> metadata)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
throw new ArgumentException("Key must not be null or empty.", nameof(key));
|
||||
if (metadata == null)
|
||||
throw new ArgumentNullException(nameof(metadata));
|
||||
|
||||
var metadataCopy = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
foreach (var pair in metadata)
|
||||
metadataCopy[pair.Key] = pair.Value;
|
||||
var overlay = new Dictionary<string, IReadOnlyDictionary<string, object?>>(StringComparer.Ordinal)
|
||||
{
|
||||
[key] = metadataCopy
|
||||
};
|
||||
return new ShrinkCtx(Runtime, this, OwnerFiber, null, overlay);
|
||||
}
|
||||
|
||||
public ShrinkCtx Intercept<T>(ShrinkKey<T> key, IReadOnlyDictionary<string, object?> metadata) =>
|
||||
Intercept((key ?? throw new ArgumentNullException(nameof(key))).Id, metadata);
|
||||
|
||||
internal ShrinkCtx CreateChildForFiber(ShrinkFiber fiber) => new(Runtime, this, fiber, null);
|
||||
|
||||
/// <summary>在派生子上下文中叠加隔离覆盖(同键后者优先),供加载器为条目配置隔离域。</summary>
|
||||
@@ -180,6 +226,38 @@ namespace ShrinkContext
|
||||
return new ShrinkCtx(Runtime, this, OwnerFiber, overlay);
|
||||
}
|
||||
|
||||
/// <summary>在当前纤程上下文上叠加 loader 条目配置的 intercept。</summary>
|
||||
internal ShrinkCtx WithIntercept(
|
||||
IReadOnlyDictionary<string, IReadOnlyDictionary<string, object?>> intercept) =>
|
||||
new(Runtime, this, OwnerFiber, null, interceptOverlay: intercept);
|
||||
|
||||
/// <summary>原地替换本层 intercept,供 loader 在不重载 fiber 的情况下更新访问策略。</summary>
|
||||
internal void ReplaceIntercept(
|
||||
IReadOnlyDictionary<string, IReadOnlyDictionary<string, object?>>? intercept)
|
||||
{
|
||||
if (intercept == null || intercept.Count == 0)
|
||||
{
|
||||
_interceptOverlay = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var copy = new Dictionary<string, IReadOnlyDictionary<string, object?>>(StringComparer.Ordinal);
|
||||
foreach (var pair in intercept)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key))
|
||||
throw new ArgumentException("Intercept key must not be empty.", nameof(intercept));
|
||||
if (pair.Value == null)
|
||||
throw new ArgumentException($"Intercept metadata for '{pair.Key}' must not be null.",
|
||||
nameof(intercept));
|
||||
var metadataCopy = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
foreach (var metadataPair in pair.Value)
|
||||
metadataCopy[metadataPair.Key] = metadataPair.Value;
|
||||
copy[pair.Key] = metadataCopy;
|
||||
}
|
||||
|
||||
_interceptOverlay = copy;
|
||||
}
|
||||
|
||||
/// <summary>解析 key 在本上下文中归属的 realm:沿父链找最近的隔离覆盖,默认 realm 为 key 自身。</summary>
|
||||
internal string ResolveRealm(string key)
|
||||
{
|
||||
@@ -193,5 +271,30 @@ namespace ShrinkContext
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/// <summary>按根到叶顺序合并拦截元数据;越接近访问方的上下文优先。</summary>
|
||||
internal IReadOnlyDictionary<string, object?> ResolveInterceptMetadata(string key)
|
||||
{
|
||||
var chain = new Stack<ShrinkCtx>();
|
||||
for (var current = this; current != null; current = current.Parent)
|
||||
chain.Push(current);
|
||||
|
||||
var merged = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
while (chain.Count > 0)
|
||||
{
|
||||
var current = chain.Pop();
|
||||
if (current._interceptOverlay == null ||
|
||||
!current._interceptOverlay.TryGetValue(key, out var metadata))
|
||||
continue;
|
||||
|
||||
foreach (var pair in metadata)
|
||||
merged[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
internal IReadOnlyCollection<string> GetLocalInterceptKeys() =>
|
||||
_interceptOverlay?.Keys ?? (IReadOnlyCollection<string>)Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ namespace ShrinkContext
|
||||
Parent = parent;
|
||||
Config = config;
|
||||
InjectSet = new HashSet<string>(component.Inject ?? Array.Empty<string>(), StringComparer.Ordinal);
|
||||
ProvideSet = new HashSet<string>(component.Provide ?? Array.Empty<string>(), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public long Uid { get; }
|
||||
@@ -62,5 +63,6 @@ namespace ShrinkContext
|
||||
internal bool InTransition;
|
||||
internal bool Retired;
|
||||
internal HashSet<string> InjectSet { get; }
|
||||
internal HashSet<string> ProvideSet { get; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cysharp.Threading.Tasks;
|
||||
|
||||
namespace ShrinkContext
|
||||
@@ -9,7 +10,8 @@ namespace ShrinkContext
|
||||
public sealed class ShrinkLoaderEntry
|
||||
{
|
||||
public ShrinkLoaderEntry(string id, string component, object? config = null, bool disabled = false,
|
||||
IReadOnlyDictionary<string, string>? isolate = null)
|
||||
IReadOnlyDictionary<string, string>? isolate = null,
|
||||
IReadOnlyDictionary<string, IReadOnlyDictionary<string, object?>>? intercept = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
throw new ArgumentException("Entry id must not be null or empty.", nameof(id));
|
||||
@@ -21,6 +23,7 @@ namespace ShrinkContext
|
||||
Config = config;
|
||||
Disabled = disabled;
|
||||
Isolate = isolate;
|
||||
Intercept = intercept;
|
||||
}
|
||||
|
||||
/// <summary>稳定协调键:条目增删与更新的 diff 依据。</summary>
|
||||
@@ -37,6 +40,9 @@ namespace ShrinkContext
|
||||
|
||||
/// <summary>条目级隔离域:键 → realm,叠加到该纤程自身的解析。</summary>
|
||||
public IReadOnlyDictionary<string, string>? Isolate { get; }
|
||||
|
||||
/// <summary>条目级访问元数据:依赖键 → metadata。更新时不改变 target,也不重载 fiber。</summary>
|
||||
public IReadOnlyDictionary<string, IReadOnlyDictionary<string, object?>>? Intercept { get; }
|
||||
}
|
||||
|
||||
/// <summary>组件目录:条目的组件名 → 组件工厂。装配期由宿主/CodeGen 注册,加载器不做全域反射扫描。</summary>
|
||||
@@ -69,23 +75,68 @@ namespace ShrinkContext
|
||||
component = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool Contains(string name) => _factories.ContainsKey(name);
|
||||
}
|
||||
|
||||
/// <summary>加载器配置错误(重复条目 id、未知组件名等)。</summary>
|
||||
public sealed class ShrinkLoaderException : Exception
|
||||
public class ShrinkLoaderException : Exception
|
||||
{
|
||||
public ShrinkLoaderException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public enum ShrinkLoaderTransactionPhase
|
||||
{
|
||||
Idle = 0,
|
||||
Validating = 1,
|
||||
Removing = 2,
|
||||
Applying = 3,
|
||||
Restoring = 4,
|
||||
Completed = 5,
|
||||
Failed = 6,
|
||||
}
|
||||
|
||||
/// <summary>最近一次声明式协调的稳定诊断结果。</summary>
|
||||
public sealed class ShrinkLoaderTransactionDiagnostic
|
||||
{
|
||||
internal ShrinkLoaderTransactionDiagnostic(long generation)
|
||||
{
|
||||
Generation = generation;
|
||||
}
|
||||
|
||||
public long Generation { get; }
|
||||
public ShrinkLoaderTransactionPhase Phase { get; internal set; }
|
||||
public string? CurrentEntryId { get; internal set; }
|
||||
public string? ErrorType { get; internal set; }
|
||||
public string? ErrorMessage { get; internal set; }
|
||||
public bool RestoreAttempted { get; internal set; }
|
||||
public bool PreviousCompositionRestored { get; internal set; }
|
||||
public string? RestoreErrorType { get; internal set; }
|
||||
public string? RestoreErrorMessage { get; internal set; }
|
||||
}
|
||||
|
||||
public sealed class ShrinkLoaderRestoreException : ShrinkLoaderException
|
||||
{
|
||||
public ShrinkLoaderRestoreException(Exception applyError, Exception restoreError)
|
||||
: base("Loader apply failed and restoring the previous composition also failed.")
|
||||
{
|
||||
ApplyError = applyError;
|
||||
RestoreError = restoreError;
|
||||
}
|
||||
|
||||
public Exception ApplyError { get; }
|
||||
public Exception RestoreError { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 声明式组件加载器(论文 5.2 的原型子集):
|
||||
/// 编排者把期望组合表达为条目列表,加载器将其增量协调为纤程操作——
|
||||
/// 条目消失 → 退役;disabled → 卸载;组件/配置变化 → 重建;其余保持不动(幂等)。
|
||||
///
|
||||
/// 与完整实现的差异(后续阶段补齐):config 变化当前统一走重建而非组件自决 diff;
|
||||
/// isolate 变化走重建而非领域原地重分配;intercept 尚未支持。
|
||||
/// 与完整实现的差异:config 变化当前统一走重建而非组件自决 diff;
|
||||
/// isolate 变化仍走重建而非领域原地重分配。intercept metadata 可原地更新,不触发重载。
|
||||
/// </summary>
|
||||
public sealed class ShrinkContextLoader
|
||||
{
|
||||
@@ -95,12 +146,15 @@ namespace ShrinkContext
|
||||
public object? Config;
|
||||
public bool Disabled;
|
||||
public IReadOnlyDictionary<string, string>? Isolate;
|
||||
public IReadOnlyDictionary<string, IReadOnlyDictionary<string, object?>>? Intercept;
|
||||
public ShrinkFiber? Fiber;
|
||||
}
|
||||
|
||||
private readonly ShrinkContextRuntime _runtime;
|
||||
private readonly ShrinkComponentCatalog _catalog;
|
||||
private readonly Dictionary<string, ManagedEntry> _entries = new(StringComparer.Ordinal);
|
||||
private long _transactionGeneration;
|
||||
private bool _applying;
|
||||
|
||||
public ShrinkContextLoader(ShrinkContextRuntime runtime, ShrinkComponentCatalog catalog)
|
||||
{
|
||||
@@ -111,6 +165,8 @@ namespace ShrinkContext
|
||||
/// <summary>当前托管条目 id(含 disabled 但仍被管理的条目)。</summary>
|
||||
public IEnumerable<string> ManagedEntryIds => _entries.Keys;
|
||||
|
||||
public ShrinkLoaderTransactionDiagnostic? LastTransaction { get; private set; }
|
||||
|
||||
/// <summary>查询条目当前纤程(可能处于 Inactive——依赖缺失时被响应式停用但仍被管理)。</summary>
|
||||
public bool TryGetFiber(string entryId, out ShrinkFiber fiber)
|
||||
{
|
||||
@@ -132,15 +188,71 @@ namespace ShrinkContext
|
||||
{
|
||||
if (desired == null)
|
||||
throw new ArgumentNullException(nameof(desired));
|
||||
if (_applying)
|
||||
throw new InvalidOperationException("A loader composition transaction is already running.");
|
||||
|
||||
var previous = CaptureEntries();
|
||||
var transaction = new ShrinkLoaderTransactionDiagnostic(++_transactionGeneration);
|
||||
LastTransaction = transaction;
|
||||
_applying = true;
|
||||
try
|
||||
{
|
||||
await ApplyCoreAsync(desired, transaction, restoring: false);
|
||||
transaction.Phase = ShrinkLoaderTransactionPhase.Completed;
|
||||
transaction.CurrentEntryId = null;
|
||||
}
|
||||
catch (Exception applyError)
|
||||
{
|
||||
transaction.ErrorType = applyError.GetType().FullName;
|
||||
transaction.ErrorMessage = applyError.Message;
|
||||
transaction.RestoreAttempted = true;
|
||||
transaction.Phase = ShrinkLoaderTransactionPhase.Restoring;
|
||||
transaction.CurrentEntryId = null;
|
||||
|
||||
try
|
||||
{
|
||||
await ApplyCoreAsync(previous, transaction, restoring: true);
|
||||
transaction.PreviousCompositionRestored = true;
|
||||
}
|
||||
catch (Exception restoreError)
|
||||
{
|
||||
transaction.RestoreErrorType = restoreError.GetType().FullName;
|
||||
transaction.RestoreErrorMessage = restoreError.Message;
|
||||
transaction.Phase = ShrinkLoaderTransactionPhase.Failed;
|
||||
transaction.CurrentEntryId = null;
|
||||
throw new ShrinkLoaderRestoreException(applyError, restoreError);
|
||||
}
|
||||
|
||||
transaction.Phase = ShrinkLoaderTransactionPhase.Failed;
|
||||
transaction.CurrentEntryId = null;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_applying = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async UniTask ApplyCoreAsync(IReadOnlyList<ShrinkLoaderEntry> desired,
|
||||
ShrinkLoaderTransactionDiagnostic transaction, bool restoring)
|
||||
{
|
||||
transaction.Phase = restoring
|
||||
? ShrinkLoaderTransactionPhase.Restoring
|
||||
: ShrinkLoaderTransactionPhase.Validating;
|
||||
|
||||
var desiredIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var entry in desired)
|
||||
{
|
||||
if (!desiredIds.Add(entry.Id))
|
||||
throw new ShrinkLoaderException($"Duplicate loader entry id: '{entry.Id}'.");
|
||||
if (!entry.Disabled && !_catalog.Contains(entry.Component))
|
||||
throw new ShrinkLoaderException(
|
||||
$"Unknown component '{entry.Component}' requested by entry '{entry.Id}'.");
|
||||
}
|
||||
|
||||
// 1) 消失的条目退役(其依赖者由响应式通知自动停用,条目本身仍被管理)
|
||||
if (!restoring)
|
||||
transaction.Phase = ShrinkLoaderTransactionPhase.Removing;
|
||||
var removedIds = new List<string>();
|
||||
foreach (var id in _entries.Keys)
|
||||
{
|
||||
@@ -150,6 +262,7 @@ namespace ShrinkContext
|
||||
|
||||
foreach (var id in removedIds)
|
||||
{
|
||||
transaction.CurrentEntryId = id;
|
||||
var managed = _entries[id];
|
||||
if (managed.Fiber != null)
|
||||
await _runtime.RetireAsync(managed.Fiber);
|
||||
@@ -157,8 +270,11 @@ namespace ShrinkContext
|
||||
}
|
||||
|
||||
// 2) 按声明顺序逐条分派
|
||||
if (!restoring)
|
||||
transaction.Phase = ShrinkLoaderTransactionPhase.Applying;
|
||||
foreach (var entry in desired)
|
||||
{
|
||||
transaction.CurrentEntryId = entry.Id;
|
||||
_entries.TryGetValue(entry.Id, out var managed);
|
||||
|
||||
if (entry.Disabled)
|
||||
@@ -170,7 +286,8 @@ namespace ShrinkContext
|
||||
Component = entry.Component,
|
||||
Config = entry.Config,
|
||||
Disabled = true,
|
||||
Isolate = entry.Isolate
|
||||
Isolate = entry.Isolate,
|
||||
Intercept = entry.Intercept
|
||||
};
|
||||
}
|
||||
else
|
||||
@@ -179,6 +296,10 @@ namespace ShrinkContext
|
||||
await _runtime.RetireAsync(managed.Fiber);
|
||||
managed.Fiber = null;
|
||||
managed.Disabled = true;
|
||||
managed.Component = entry.Component;
|
||||
managed.Config = entry.Config;
|
||||
managed.Isolate = entry.Isolate;
|
||||
managed.Intercept = entry.Intercept;
|
||||
}
|
||||
|
||||
continue;
|
||||
@@ -189,28 +310,61 @@ namespace ShrinkContext
|
||||
Equals(managed.Config, entry.Config) &&
|
||||
IsolateEquals(managed.Isolate, entry.Isolate))
|
||||
{
|
||||
if (!InterceptEquals(managed.Intercept, entry.Intercept))
|
||||
{
|
||||
managed.Fiber.Ctx.ReplaceIntercept(entry.Intercept);
|
||||
managed.Intercept = entry.Intercept;
|
||||
}
|
||||
continue; // 幂等:无变化不打扰(被响应式停用的纤程保持管理,等待依赖回归)
|
||||
}
|
||||
|
||||
if (managed?.Fiber != null)
|
||||
{
|
||||
await _runtime.RetireAsync(managed.Fiber);
|
||||
managed.Fiber = null;
|
||||
}
|
||||
|
||||
if (!_catalog.TryCreate(entry.Component, out var component))
|
||||
throw new ShrinkLoaderException(
|
||||
$"Unknown component '{entry.Component}' requested by entry '{entry.Id}'.");
|
||||
throw new ShrinkLoaderException($"Component factory '{entry.Component}' returned no component.");
|
||||
|
||||
var fiber = _runtime.Use(component, entry.Config, _runtime.RootContext, entry.Isolate);
|
||||
var fiber = _runtime.Use(component, entry.Config, _runtime.RootContext, entry.Isolate, entry.Intercept);
|
||||
if (fiber.LastError != null)
|
||||
{
|
||||
var failure = fiber.LastError;
|
||||
await _runtime.RetireAsync(fiber);
|
||||
throw new ShrinkLoaderException(
|
||||
$"Component '{entry.Component}' for entry '{entry.Id}' failed during apply: {failure.Message}");
|
||||
}
|
||||
_entries[entry.Id] = new ManagedEntry
|
||||
{
|
||||
Component = entry.Component,
|
||||
Config = entry.Config,
|
||||
Disabled = false,
|
||||
Isolate = entry.Isolate,
|
||||
Intercept = entry.Intercept,
|
||||
Fiber = fiber
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<ShrinkLoaderEntry> CaptureEntries()
|
||||
{
|
||||
var entries = new List<ShrinkLoaderEntry>(_entries.Count);
|
||||
foreach (var pair in _entries.OrderBy(item => item.Key, StringComparer.Ordinal))
|
||||
{
|
||||
var managed = pair.Value;
|
||||
entries.Add(new ShrinkLoaderEntry(
|
||||
pair.Key,
|
||||
managed.Component,
|
||||
managed.Config,
|
||||
managed.Disabled,
|
||||
managed.Isolate,
|
||||
managed.Intercept));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static bool IsolateEquals(IReadOnlyDictionary<string, string>? a, IReadOnlyDictionary<string, string>? b)
|
||||
{
|
||||
if (a == null && b == null)
|
||||
@@ -228,5 +382,31 @@ namespace ShrinkContext
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool InterceptEquals(
|
||||
IReadOnlyDictionary<string, IReadOnlyDictionary<string, object?>>? a,
|
||||
IReadOnlyDictionary<string, IReadOnlyDictionary<string, object?>>? b)
|
||||
{
|
||||
if (a == null && b == null)
|
||||
return true;
|
||||
if (a == null || b == null || a.Count != b.Count)
|
||||
return false;
|
||||
|
||||
foreach (var keyPair in a)
|
||||
{
|
||||
if (!b.TryGetValue(keyPair.Key, out var otherMetadata) ||
|
||||
keyPair.Value.Count != otherMetadata.Count)
|
||||
return false;
|
||||
|
||||
foreach (var metadataPair in keyPair.Value)
|
||||
{
|
||||
if (!otherMetadata.TryGetValue(metadataPair.Key, out var otherValue) ||
|
||||
!Equals(metadataPair.Value, otherValue))
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace ShrinkContext
|
||||
{
|
||||
/// <summary>
|
||||
/// 默认运行时入口 + Domain Reload 安全重置。
|
||||
/// 原型阶段不自动启动任何组件;编排方式(配置树加载器)属于后续阶段。
|
||||
/// 不自动启动任何组件;上层 composition root 通过 ShrinkContextLoader 提交期望组合。
|
||||
/// </summary>
|
||||
public static class ShrinkContextDefaults
|
||||
{
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ShrinkContext
|
||||
{
|
||||
public enum ShrinkFiberDiagnosticStatus
|
||||
{
|
||||
Inactive = 0,
|
||||
Waiting = 1,
|
||||
Loading = 2,
|
||||
Active = 3,
|
||||
Unloading = 4,
|
||||
Failed = 5,
|
||||
Retired = 6,
|
||||
}
|
||||
|
||||
/// <summary>单个依赖键在快照时刻的解析、提交与潜在提供者。</summary>
|
||||
public sealed class ShrinkDependencyDiagnostic
|
||||
{
|
||||
public ShrinkDependencyDiagnostic(string key, string realm, long? currentProviderUid,
|
||||
long? committedProviderUid, IReadOnlyList<long> potentialProviderUids)
|
||||
{
|
||||
Key = key;
|
||||
Realm = realm;
|
||||
CurrentProviderUid = currentProviderUid;
|
||||
CommittedProviderUid = committedProviderUid;
|
||||
PotentialProviderUids = potentialProviderUids;
|
||||
}
|
||||
|
||||
public string Key { get; }
|
||||
public string Realm { get; }
|
||||
public long? CurrentProviderUid { get; }
|
||||
public long? CommittedProviderUid { get; }
|
||||
public IReadOnlyList<long> PotentialProviderUids { get; }
|
||||
public bool IsSatisfied => CurrentProviderUid.HasValue;
|
||||
}
|
||||
|
||||
/// <summary>一个 fiber 的稳定只读诊断投影,不暴露可变运行时对象。</summary>
|
||||
public sealed class ShrinkFiberDiagnostic
|
||||
{
|
||||
public ShrinkFiberDiagnostic(long uid, string name, ShrinkFiberDiagnosticStatus status,
|
||||
ShrinkFiberState lifecycleState, bool retired, bool inTransition,
|
||||
IReadOnlyList<string> inject, IReadOnlyList<string> provide,
|
||||
IReadOnlyList<long> targetProviderUids,
|
||||
IReadOnlyList<ShrinkDependencyDiagnostic> dependencies,
|
||||
IReadOnlyList<string> interceptKeys,
|
||||
string? errorType, string? errorMessage)
|
||||
{
|
||||
Uid = uid;
|
||||
Name = name;
|
||||
Status = status;
|
||||
LifecycleState = lifecycleState;
|
||||
Retired = retired;
|
||||
InTransition = inTransition;
|
||||
Inject = inject;
|
||||
Provide = provide;
|
||||
TargetProviderUids = targetProviderUids;
|
||||
Dependencies = dependencies;
|
||||
InterceptKeys = interceptKeys;
|
||||
ErrorType = errorType;
|
||||
ErrorMessage = errorMessage;
|
||||
}
|
||||
|
||||
public long Uid { get; }
|
||||
public string Name { get; }
|
||||
public ShrinkFiberDiagnosticStatus Status { get; }
|
||||
public ShrinkFiberState LifecycleState { get; }
|
||||
public bool Retired { get; }
|
||||
public bool InTransition { get; }
|
||||
public IReadOnlyList<string> Inject { get; }
|
||||
public IReadOnlyList<string> Provide { get; }
|
||||
public IReadOnlyList<long> TargetProviderUids { get; }
|
||||
public IReadOnlyList<ShrinkDependencyDiagnostic> Dependencies { get; }
|
||||
public IReadOnlyList<string> InterceptKeys { get; }
|
||||
public string? ErrorType { get; }
|
||||
public string? ErrorMessage { get; }
|
||||
}
|
||||
|
||||
public sealed class ShrinkNotificationDiagnostic
|
||||
{
|
||||
public ShrinkNotificationDiagnostic(long dispatchCount, long candidateVisitCount,
|
||||
int lastCandidateCount, int indexedKeyCount)
|
||||
{
|
||||
DispatchCount = dispatchCount;
|
||||
CandidateVisitCount = candidateVisitCount;
|
||||
LastCandidateCount = lastCandidateCount;
|
||||
IndexedKeyCount = indexedKeyCount;
|
||||
}
|
||||
|
||||
public long DispatchCount { get; }
|
||||
public long CandidateVisitCount { get; }
|
||||
public int LastCandidateCount { get; }
|
||||
public int IndexedKeyCount { get; }
|
||||
}
|
||||
|
||||
/// <summary>运行时整体快照:fiber、绑定与 notify 索引状态。</summary>
|
||||
public sealed class ShrinkContextRuntimeDiagnostic
|
||||
{
|
||||
public ShrinkContextRuntimeDiagnostic(IReadOnlyList<ShrinkFiberDiagnostic> fibers,
|
||||
int bindingCount, ShrinkNotificationDiagnostic notifications)
|
||||
{
|
||||
Fibers = fibers;
|
||||
BindingCount = bindingCount;
|
||||
Notifications = notifications;
|
||||
}
|
||||
|
||||
public IReadOnlyList<ShrinkFiberDiagnostic> Fibers { get; }
|
||||
public int BindingCount { get; }
|
||||
public ShrinkNotificationDiagnostic Notifications { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1ee8044af3885c84a9b86b101af8ae28
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -13,14 +13,20 @@ namespace ShrinkContext
|
||||
/// 约定:
|
||||
/// - 仅在主线程使用;
|
||||
/// - 异步转换在同步可完成路径上内联跑完(无需 PlayerLoop 泵即可确定性测试);
|
||||
/// - 目标(Target)以提供者 uid 集合表示,uid 永不复用,因此“同值不同提供者”的替换也会触发重载。
|
||||
/// - 目标(Target)按 inject 声明顺序记录 provider uid 视图,uid 永不复用,
|
||||
/// 因此“同值不同提供者”的替换及多键 provider 映射变化都能触发重载。
|
||||
/// </summary>
|
||||
public sealed class ShrinkContextRuntime
|
||||
{
|
||||
private readonly ShrinkCtx _rootCtx;
|
||||
private readonly List<ShrinkFiber> _fibers = new();
|
||||
private readonly Dictionary<string, ShrinkBinding> _store = new();
|
||||
private readonly Dictionary<string, HashSet<ShrinkFiber>> _injectIndex =
|
||||
new(StringComparer.Ordinal);
|
||||
private long _uidCounter;
|
||||
private long _notificationDispatchCount;
|
||||
private long _notificationCandidateVisitCount;
|
||||
private int _lastNotificationCandidateCount;
|
||||
|
||||
public ShrinkContextRuntime()
|
||||
{
|
||||
@@ -39,7 +45,8 @@ namespace ShrinkContext
|
||||
/// isolate 为该纤程自身的键解析叠加隔离域(不影响父上下文)。
|
||||
/// </summary>
|
||||
public ShrinkFiber Use(IShrinkComponent component, object? config = null, ShrinkCtx? parentCtx = null,
|
||||
IReadOnlyDictionary<string, string>? isolate = null)
|
||||
IReadOnlyDictionary<string, string>? isolate = null,
|
||||
IReadOnlyDictionary<string, IReadOnlyDictionary<string, object?>>? intercept = null)
|
||||
{
|
||||
if (component == null)
|
||||
throw new ArgumentNullException(nameof(component));
|
||||
@@ -49,8 +56,11 @@ namespace ShrinkContext
|
||||
var fiberCtx = parent.CreateChildForFiber(fiber);
|
||||
if (isolate != null && isolate.Count > 0)
|
||||
fiberCtx = fiberCtx.WithIsolate(isolate);
|
||||
if (intercept != null && intercept.Count > 0)
|
||||
fiberCtx = fiberCtx.WithIntercept(intercept);
|
||||
fiber.Ctx = fiberCtx;
|
||||
_fibers.Add(fiber);
|
||||
IndexFiber(fiber);
|
||||
|
||||
var instantiationHandle = new ShrinkEffectHandle();
|
||||
instantiationHandle.AttachInverse(() => RetireCoreAsync(fiber));
|
||||
@@ -68,6 +78,7 @@ namespace ShrinkContext
|
||||
{
|
||||
if (fiber.Retired)
|
||||
return;
|
||||
UnindexFiber(fiber);
|
||||
if (fiber.State == ShrinkFiberState.Inactive && !fiber.InTransition)
|
||||
{
|
||||
fiber.Retired = true;
|
||||
@@ -97,7 +108,7 @@ namespace ShrinkContext
|
||||
// ---------------- 生命周期(论文算法 5) ----------------
|
||||
|
||||
/// <summary>
|
||||
/// 重算目标:任一依赖键不可解析 → ⊥;否则为全部提供者 uid 的有序集合。
|
||||
/// 重算目标:任一依赖键不可解析 → ⊥;否则为按 inject 键顺序记录的 provider uid 视图。
|
||||
/// 目标变化即启动(或在惯性边界链式触发)reload / unload 转换。
|
||||
/// </summary>
|
||||
internal bool Refresh(ShrinkFiber fiber)
|
||||
@@ -222,19 +233,38 @@ namespace ShrinkContext
|
||||
internal IReadOnlyList<ShrinkFiber> Notify(ShrinkCtx sourceCtx, IReadOnlyCollection<string> keys)
|
||||
{
|
||||
var affected = new List<ShrinkFiber>();
|
||||
foreach (var fiber in _fibers.ToArray())
|
||||
if (keys.Count == 0)
|
||||
return affected;
|
||||
|
||||
var candidates = new HashSet<ShrinkFiber>();
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (_injectIndex.TryGetValue(key, out var indexed))
|
||||
candidates.UnionWith(indexed);
|
||||
}
|
||||
|
||||
_notificationDispatchCount++;
|
||||
_lastNotificationCandidateCount = candidates.Count;
|
||||
_notificationCandidateVisitCount += candidates.Count;
|
||||
|
||||
foreach (var fiber in candidates.OrderBy(candidate => candidate.Uid))
|
||||
{
|
||||
if (fiber.Retired)
|
||||
continue;
|
||||
|
||||
var realmMatches = false;
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (!fiber.InjectSet.Contains(key))
|
||||
continue;
|
||||
if (fiber.Ctx.ResolveRealm(key) != sourceCtx.ResolveRealm(key))
|
||||
continue;
|
||||
|
||||
if (Refresh(fiber))
|
||||
affected.Add(fiber);
|
||||
realmMatches = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (realmMatches && Refresh(fiber))
|
||||
affected.Add(fiber);
|
||||
}
|
||||
|
||||
return affected;
|
||||
@@ -242,12 +272,18 @@ namespace ShrinkContext
|
||||
|
||||
// ---------------- 余效应存储(论文算法 2) ----------------
|
||||
|
||||
internal ShrinkEffectHandle SetBinding(ShrinkCtx ctx, string key, object? value)
|
||||
internal ShrinkEffectHandle SetBinding(ShrinkCtx ctx, string key, object? value,
|
||||
IShrinkCoeffectAccessPolicy? accessPolicy)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
throw new ArgumentException("Key must not be null or empty.", nameof(key));
|
||||
|
||||
var realm = ctx.ResolveRealm(key);
|
||||
var setter = ctx.OwnerFiber;
|
||||
if (setter != null && !setter.ProvideSet.Contains(key))
|
||||
throw new ShrinkUndeclaredSupplyException(key, setter.Name);
|
||||
|
||||
InstallBinding(realm, key, value, setter, ctx);
|
||||
InstallBinding(realm, key, value, setter, ctx, accessPolicy);
|
||||
|
||||
var handle = new ShrinkEffectHandle();
|
||||
handle.AttachInverse(() =>
|
||||
@@ -259,12 +295,13 @@ namespace ShrinkContext
|
||||
return handle;
|
||||
}
|
||||
|
||||
private void InstallBinding(string realm, string key, object? value, ShrinkFiber? setter, ShrinkCtx ctx)
|
||||
private void InstallBinding(string realm, string key, object? value, ShrinkFiber? setter, ShrinkCtx ctx,
|
||||
IShrinkCoeffectAccessPolicy? accessPolicy)
|
||||
{
|
||||
if (_store.TryGetValue(realm, out var existing) && !IsTakeoverAllowed(existing, setter))
|
||||
throw new ShrinkSupplyConflictException(key, existing.Provider, setter);
|
||||
|
||||
_store[realm] = new ShrinkBinding(value, setter, key);
|
||||
_store[realm] = new ShrinkBinding(value, setter, key, accessPolicy);
|
||||
Notify(ctx, new[] { key });
|
||||
}
|
||||
|
||||
@@ -310,6 +347,33 @@ namespace ShrinkContext
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryGetRaw<T>(ShrinkCtx ctx, ShrinkKey<T> key, out T value) => TryGetRaw(
|
||||
ctx, (key ?? throw new ArgumentNullException(nameof(key))).Id, out value);
|
||||
|
||||
/// <summary>执行 provider 绑定的访问策略;无策略时直接返回原始值。</summary>
|
||||
internal T ResolveAccess<T>(ShrinkCtx ctx, string key, ShrinkBinding binding)
|
||||
{
|
||||
object? value = binding.Value;
|
||||
if (binding.AccessPolicy != null)
|
||||
{
|
||||
var access = new ShrinkCoeffectAccessContext(
|
||||
key,
|
||||
typeof(T),
|
||||
ctx.OwnerFiber,
|
||||
ctx.ResolveInterceptMetadata(key));
|
||||
value = binding.AccessPolicy.Resolve(access, value);
|
||||
}
|
||||
|
||||
if (value is T typed)
|
||||
return typed;
|
||||
if (value == null && (!typeof(T).IsValueType || Nullable.GetUnderlyingType(typeof(T)) != null))
|
||||
return default!;
|
||||
|
||||
throw new InvalidCastException(
|
||||
$"Coeffect '{key}' resolved value of type '{value?.GetType().FullName ?? "<null>"}' " +
|
||||
$"but consumer requested '{typeof(T).FullName}'.");
|
||||
}
|
||||
|
||||
// ---------------- 目标计算 ----------------
|
||||
|
||||
private long[]? ComputeTarget(ShrinkFiber fiber)
|
||||
@@ -323,7 +387,9 @@ namespace ShrinkContext
|
||||
uids.Add(binding.Provider?.Uid ?? 0);
|
||||
}
|
||||
|
||||
return uids.Distinct().OrderBy(uid => uid).ToArray();
|
||||
// target 是 key -> provider uid 的视图,不是 uid 集合。保留 inject 声明顺序与重复 uid,
|
||||
// 才能观察两个键在相同提供者集合之间互换的变化。
|
||||
return uids.ToArray();
|
||||
}
|
||||
|
||||
private Dictionary<string, ShrinkBinding> ResolveView(ShrinkFiber fiber)
|
||||
@@ -361,5 +427,102 @@ namespace ShrinkContext
|
||||
return;
|
||||
await RetireAsync(fiber);
|
||||
}
|
||||
|
||||
private void IndexFiber(ShrinkFiber fiber)
|
||||
{
|
||||
foreach (var key in fiber.InjectSet)
|
||||
{
|
||||
if (!_injectIndex.TryGetValue(key, out var fibers))
|
||||
{
|
||||
fibers = new HashSet<ShrinkFiber>();
|
||||
_injectIndex.Add(key, fibers);
|
||||
}
|
||||
|
||||
fibers.Add(fiber);
|
||||
}
|
||||
}
|
||||
|
||||
private void UnindexFiber(ShrinkFiber fiber)
|
||||
{
|
||||
foreach (var key in fiber.InjectSet)
|
||||
{
|
||||
if (!_injectIndex.TryGetValue(key, out var fibers))
|
||||
continue;
|
||||
fibers.Remove(fiber);
|
||||
if (fibers.Count == 0)
|
||||
_injectIndex.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>捕获不含可变运行时引用的诊断快照。</summary>
|
||||
public ShrinkContextRuntimeDiagnostic CaptureDiagnostic()
|
||||
{
|
||||
var diagnostics = new List<ShrinkFiberDiagnostic>(_fibers.Count);
|
||||
foreach (var fiber in _fibers.OrderBy(item => item.Uid))
|
||||
{
|
||||
var dependencies = new List<ShrinkDependencyDiagnostic>();
|
||||
foreach (var key in fiber.Component.Inject ?? Array.Empty<string>())
|
||||
{
|
||||
var realm = fiber.Ctx.ResolveRealm(key);
|
||||
var current = ResolveBinding(fiber.Ctx, key);
|
||||
ShrinkBinding? committed = null;
|
||||
fiber.Committed?.TryGetValue(key, out committed);
|
||||
var potential = _fibers
|
||||
.Where(candidate => !candidate.Retired && candidate.ProvideSet.Contains(key) &&
|
||||
candidate.Ctx.ResolveRealm(key) == realm)
|
||||
.Select(candidate => candidate.Uid)
|
||||
.OrderBy(uid => uid)
|
||||
.ToArray();
|
||||
|
||||
dependencies.Add(new ShrinkDependencyDiagnostic(
|
||||
key,
|
||||
realm,
|
||||
current?.Provider?.Uid,
|
||||
committed?.Provider?.Uid,
|
||||
potential));
|
||||
}
|
||||
|
||||
diagnostics.Add(new ShrinkFiberDiagnostic(
|
||||
fiber.Uid,
|
||||
fiber.Name,
|
||||
GetDiagnosticStatus(fiber),
|
||||
fiber.State,
|
||||
fiber.Retired,
|
||||
fiber.InTransition,
|
||||
(fiber.Component.Inject ?? Array.Empty<string>()).ToArray(),
|
||||
(fiber.Component.Provide ?? Array.Empty<string>()).ToArray(),
|
||||
fiber.Target?.ToArray() ?? Array.Empty<long>(),
|
||||
dependencies,
|
||||
fiber.Ctx.GetLocalInterceptKeys().OrderBy(key => key, StringComparer.Ordinal).ToArray(),
|
||||
fiber.LastError?.GetType().FullName,
|
||||
fiber.LastError?.Message));
|
||||
}
|
||||
|
||||
return new ShrinkContextRuntimeDiagnostic(
|
||||
diagnostics,
|
||||
_store.Count,
|
||||
new ShrinkNotificationDiagnostic(
|
||||
_notificationDispatchCount,
|
||||
_notificationCandidateVisitCount,
|
||||
_lastNotificationCandidateCount,
|
||||
_injectIndex.Count));
|
||||
}
|
||||
|
||||
private static ShrinkFiberDiagnosticStatus GetDiagnosticStatus(ShrinkFiber fiber)
|
||||
{
|
||||
if (fiber.Retired)
|
||||
return ShrinkFiberDiagnosticStatus.Retired;
|
||||
if (fiber.LastError != null)
|
||||
return ShrinkFiberDiagnosticStatus.Failed;
|
||||
|
||||
return fiber.State switch
|
||||
{
|
||||
ShrinkFiberState.Loading => ShrinkFiberDiagnosticStatus.Loading,
|
||||
ShrinkFiberState.Active => ShrinkFiberDiagnosticStatus.Active,
|
||||
ShrinkFiberState.Unloading => ShrinkFiberDiagnosticStatus.Unloading,
|
||||
ShrinkFiberState.Inactive when fiber.InjectSet.Count > 0 => ShrinkFiberDiagnosticStatus.Waiting,
|
||||
_ => ShrinkFiberDiagnosticStatus.Inactive,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,40 @@ namespace ShrinkContext.Tests
|
||||
Assert.AreEqual(1, rootValueFinal);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TypedKey_PreservesVersionedIdentityAcrossMediatedAccess()
|
||||
{
|
||||
var key = new ShrinkKey<string>("test.package", "typed-service", 2);
|
||||
Assert.AreEqual("test.package/typed-service@v2", key.Id);
|
||||
|
||||
_runtime.Use(new PolicyProviderComponent(key.Id), "typed-value");
|
||||
var consumer = _runtime.Use(new PolicyConsumerComponent(key.Id));
|
||||
|
||||
Assert.AreEqual(ShrinkFiberState.Active, consumer.State);
|
||||
Assert.AreEqual("typed-value", consumer.Ctx.Get(key));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Intercept_MergesRootToLeafAndChildMetadataWins()
|
||||
{
|
||||
const string key = "policy.service";
|
||||
_runtime.Use(new PolicyProviderComponent(key), "value");
|
||||
var consumer = _runtime.Use(new PolicyConsumerComponent(key));
|
||||
Assert.AreEqual(ShrinkFiberState.Active, consumer.State);
|
||||
|
||||
var parent = consumer.Ctx.Intercept(key, new Dictionary<string, object?>
|
||||
{
|
||||
["prefix"] = "parent-",
|
||||
["suffix"] = "-parent"
|
||||
});
|
||||
var child = parent.Intercept(key, new Dictionary<string, object?>
|
||||
{
|
||||
["suffix"] = "-child"
|
||||
});
|
||||
|
||||
Assert.AreEqual("parent-value-child", child.Get<string>(key));
|
||||
}
|
||||
|
||||
/// <summary>声明一个键却访问另一个未声明键的组件(验证能力介导纪律)。</summary>
|
||||
private sealed class GreedyConsumerComponent : IShrinkComponent
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using ShrinkContext;
|
||||
@@ -155,6 +156,57 @@ namespace ShrinkContext.Tests
|
||||
Assert.AreEqual(ShrinkFiberState.Active, firstFiber.State);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void UndeclaredSupply_FailsFiberAndLeavesNoBinding()
|
||||
{
|
||||
var fiber = _runtime.Use(new UndeclaredProviderComponent());
|
||||
|
||||
Assert.AreEqual(ShrinkFiberState.Inactive, fiber.State);
|
||||
Assert.IsInstanceOf<ShrinkUndeclaredSupplyException>(fiber.LastError);
|
||||
Assert.IsFalse(_runtime.TryGetRaw<int>(_runtime.RootContext, "not-declared", out _));
|
||||
|
||||
var snapshot = _runtime.CaptureDiagnostic();
|
||||
var diagnostic = snapshot.Fibers.Single(item => item.Uid == fiber.Uid);
|
||||
Assert.AreEqual(ShrinkFiberDiagnosticStatus.Failed, diagnostic.Status);
|
||||
StringAssert.Contains("not-declared", diagnostic.ErrorMessage);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DiagnosticSnapshot_ExplainsWaitingDependencyAndPotentialProvider()
|
||||
{
|
||||
var waiting = _runtime.Use(new ConsumerComponent("waiting", "svc"));
|
||||
var blockedProvider = _runtime.Use(new ProviderComponent("blocked-provider", "svc")
|
||||
{
|
||||
Inject = new[] { "missing" }
|
||||
});
|
||||
|
||||
var snapshot = _runtime.CaptureDiagnostic();
|
||||
var waitingDiagnostic = snapshot.Fibers.Single(item => item.Uid == waiting.Uid);
|
||||
Assert.AreEqual(ShrinkFiberDiagnosticStatus.Waiting, waitingDiagnostic.Status);
|
||||
Assert.AreEqual(1, waitingDiagnostic.Dependencies.Count);
|
||||
Assert.IsFalse(waitingDiagnostic.Dependencies[0].IsSatisfied);
|
||||
CollectionAssert.Contains(waitingDiagnostic.Dependencies[0].PotentialProviderUids,
|
||||
blockedProvider.Uid,
|
||||
"等待链应显示声明了该键、但自身依赖未满足的潜在提供者");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NotifyIndex_VisitsOnlyConsumersOfChangedKey()
|
||||
{
|
||||
var target = new ConsumerComponent("target", "target-key");
|
||||
_runtime.Use(target);
|
||||
for (var i = 0; i < 40; i++)
|
||||
_runtime.Use(new ConsumerComponent("unrelated-" + i, "other-" + i));
|
||||
|
||||
_runtime.Use(new ProviderComponent("provider", "target-key", "value"));
|
||||
|
||||
var notifications = _runtime.CaptureDiagnostic().Notifications;
|
||||
Assert.AreEqual(1, notifications.LastCandidateCount,
|
||||
"notify 应从 key 倒排索引取得候选,不扫描 40 个无关 fiber");
|
||||
Assert.AreEqual(41, notifications.IndexedKeyCount);
|
||||
Assert.AreEqual("value", target.LastSeen);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CircularDependencies_BothStayInactiveWithoutError()
|
||||
{
|
||||
|
||||
@@ -24,6 +24,9 @@ namespace ShrinkContext.Tests
|
||||
_catalog.Register("provider", () => new ConfigProviderComponent("provider", "svc"));
|
||||
_catalog.Register("provider.alt", () => new ConfigProviderComponent("provider.alt", "svc"));
|
||||
_catalog.Register("consumer", () => new ConsumerComponent("consumer", "svc"));
|
||||
_catalog.Register("failing", () => new FailingComponent("failing", "svc"));
|
||||
_catalog.Register("policy.provider", () => new PolicyProviderComponent("policy.service"));
|
||||
_catalog.Register("policy.consumer", () => new PolicyConsumerComponent("policy.service"));
|
||||
_loader = new ShrinkContextLoader(_runtime, _catalog);
|
||||
}
|
||||
|
||||
@@ -223,5 +226,87 @@ namespace ShrinkContext.Tests
|
||||
Assert.AreEqual("from-r2", ((ConsumerComponent)consumerFiber.Component).LastSeen,
|
||||
"消费者按自身隔离域解析到 r2 的绑定");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsolatedProviderReplacement_DoesNotReloadOtherRealmConsumer()
|
||||
{
|
||||
var isolateR1 = new Dictionary<string, string> { ["svc"] = "r1" };
|
||||
var isolateR2 = new Dictionary<string, string> { ["svc"] = "r2" };
|
||||
TestAwait.Run(_loader.ApplyAsync(new List<ShrinkLoaderEntry>
|
||||
{
|
||||
new("p1", "provider", "r1-v1", isolate: isolateR1),
|
||||
new("p2", "provider", "r2-v1", isolate: isolateR2),
|
||||
new("c2", "consumer", isolate: isolateR2),
|
||||
}));
|
||||
Assert.IsTrue(_loader.TryGetFiber("c2", out var consumerFiber));
|
||||
var consumer = (ConsumerComponent)consumerFiber.Component;
|
||||
Assert.AreEqual(1, consumer.LoadCount);
|
||||
|
||||
TestAwait.Run(_loader.ApplyAsync(new List<ShrinkLoaderEntry>
|
||||
{
|
||||
new("p1", "provider", "r1-v2", isolate: isolateR1),
|
||||
new("p2", "provider", "r2-v1", isolate: isolateR2),
|
||||
new("c2", "consumer", isolate: isolateR2),
|
||||
}));
|
||||
|
||||
Assert.AreEqual(1, consumer.LoadCount,
|
||||
"r1 提供者替换不得触发只解析 r2 的消费者重载");
|
||||
Assert.AreEqual("r2-v1", consumer.LastSeen);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InterceptUpdate_ChangesAccessWithoutReloadingFiber()
|
||||
{
|
||||
var suffixA = new Dictionary<string, IReadOnlyDictionary<string, object?>>
|
||||
{
|
||||
["policy.service"] = new Dictionary<string, object?> { ["suffix"] = "-a" }
|
||||
};
|
||||
var suffixB = new Dictionary<string, IReadOnlyDictionary<string, object?>>
|
||||
{
|
||||
["policy.service"] = new Dictionary<string, object?> { ["suffix"] = "-b" }
|
||||
};
|
||||
|
||||
TestAwait.Run(_loader.ApplyAsync(new List<ShrinkLoaderEntry>
|
||||
{
|
||||
new("policy-provider", "policy.provider", "base"),
|
||||
new("policy-consumer", "policy.consumer", intercept: suffixA),
|
||||
}));
|
||||
Assert.IsTrue(_loader.TryGetFiber("policy-consumer", out var firstFiber));
|
||||
var consumer = (PolicyConsumerComponent)firstFiber.Component;
|
||||
Assert.AreEqual("base-a", consumer.Read());
|
||||
Assert.AreEqual(1, consumer.ApplyCount);
|
||||
|
||||
TestAwait.Run(_loader.ApplyAsync(new List<ShrinkLoaderEntry>
|
||||
{
|
||||
new("policy-provider", "policy.provider", "base"),
|
||||
new("policy-consumer", "policy.consumer", intercept: suffixB),
|
||||
}));
|
||||
|
||||
Assert.IsTrue(_loader.TryGetFiber("policy-consumer", out var sameFiber));
|
||||
Assert.AreSame(firstFiber, sameFiber);
|
||||
Assert.AreEqual(1, consumer.ApplyCount, "intercept 元数据变化不应触发 reload");
|
||||
Assert.AreEqual("base-b", consumer.Read(), "访问时应读取更新后的 metadata");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FailedReplacement_RestoresPreviousLoaderCompositionAndReportsTransaction()
|
||||
{
|
||||
TestAwait.Run(_loader.ApplyAsync(new List<ShrinkLoaderEntry>
|
||||
{
|
||||
new("provider-entry", "provider", "stable")
|
||||
}));
|
||||
|
||||
Assert.Throws<ShrinkLoaderException>(() => TestAwait.Run(_loader.ApplyAsync(
|
||||
new List<ShrinkLoaderEntry> { new("provider-entry", "failing") })));
|
||||
|
||||
Assert.IsTrue(_loader.TryGetFiber("provider-entry", out var restored));
|
||||
Assert.AreEqual(ShrinkFiberState.Active, restored.State);
|
||||
Assert.AreEqual("stable", restored.Config);
|
||||
Assert.IsNotNull(_loader.LastTransaction);
|
||||
Assert.AreEqual(ShrinkLoaderTransactionPhase.Failed, _loader.LastTransaction!.Phase);
|
||||
Assert.IsTrue(_loader.LastTransaction.RestoreAttempted);
|
||||
Assert.IsTrue(_loader.LastTransaction.PreviousCompositionRestored);
|
||||
StringAssert.Contains("failed during apply", _loader.LastTransaction.ErrorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +250,81 @@ namespace ShrinkContext.Tests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>故意写入未声明键,验证 Provide 运行时契约。</summary>
|
||||
public sealed class UndeclaredProviderComponent : IShrinkComponent
|
||||
{
|
||||
public string Name => "undeclared-provider";
|
||||
public IReadOnlyList<string> Inject => Array.Empty<string>();
|
||||
public IReadOnlyList<string> Provide => Array.Empty<string>();
|
||||
|
||||
public UniTask ApplyAsync(ShrinkCtx ctx, object? config)
|
||||
{
|
||||
ctx.Set("not-declared", 1);
|
||||
return UniTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MetadataSuffixPolicy : IShrinkCoeffectAccessPolicy
|
||||
{
|
||||
public object? Resolve(ShrinkCoeffectAccessContext context, object? value)
|
||||
{
|
||||
var text = value?.ToString() ?? string.Empty;
|
||||
if (context.TryGetMetadata<string>("prefix", out var prefix))
|
||||
text = prefix + text;
|
||||
return context.TryGetMetadata<string>("suffix", out var suffix) ? text + suffix : text;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PolicyProviderComponent : IShrinkComponent
|
||||
{
|
||||
private readonly string[] _provide;
|
||||
|
||||
public PolicyProviderComponent(string key)
|
||||
{
|
||||
Key = key;
|
||||
_provide = new[] { key };
|
||||
}
|
||||
|
||||
public string Key { get; }
|
||||
public string Name => "policy-provider";
|
||||
public IReadOnlyList<string> Inject => Array.Empty<string>();
|
||||
public IReadOnlyList<string> Provide => _provide;
|
||||
|
||||
public UniTask ApplyAsync(ShrinkCtx ctx, object? config)
|
||||
{
|
||||
ctx.Set(Key, config?.ToString() ?? "value", new MetadataSuffixPolicy());
|
||||
return UniTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PolicyConsumerComponent : IShrinkComponent
|
||||
{
|
||||
private readonly string[] _inject;
|
||||
private ShrinkCtx? _ctx;
|
||||
|
||||
public PolicyConsumerComponent(string key)
|
||||
{
|
||||
Key = key;
|
||||
_inject = new[] { key };
|
||||
}
|
||||
|
||||
public string Key { get; }
|
||||
public string Name => "policy-consumer";
|
||||
public int ApplyCount { get; private set; }
|
||||
public IReadOnlyList<string> Inject => _inject;
|
||||
public IReadOnlyList<string> Provide => Array.Empty<string>();
|
||||
|
||||
public UniTask ApplyAsync(ShrinkCtx ctx, object? config)
|
||||
{
|
||||
_ctx = ctx;
|
||||
ApplyCount++;
|
||||
return UniTask.CompletedTask;
|
||||
}
|
||||
|
||||
public string Read() => (_ctx ?? throw new InvalidOperationException("Consumer is not active."))
|
||||
.Get<string>(Key);
|
||||
}
|
||||
|
||||
/// <summary>测试辅助:同步等待 UniTask(测试保证全部延续同步完成,无死锁风险)。</summary>
|
||||
public static class TestAwait
|
||||
{
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
本文件记录 `ShrinkDataSaver.Integration.App` 的包内变更。
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- 拆分 `IShrinkDataSaverReader` / `IShrinkDataSaverWriter` 能力契约。
|
||||
- 支持通过 ShrinkContext intercept 向派生上下文提供只读 DataSaver 视图。
|
||||
|
||||
## [0.1.0] - 2026-05-18
|
||||
|
||||
### Added
|
||||
|
||||
- 新增 `ShrinkDataSaver` 与 `ShrinkApp.Core` 的桥接包。
|
||||
- 新增 `ShrinkDataSaverAppInstaller`,把 DataSaver 纳入统一宿主启动链。
|
||||
- 新增 `ShrinkDataSaverService` 作为宿主服务门面。
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3c6cf6fb5b92c5848b40e963a80a58c1
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,29 @@
|
||||
# ShrinkDataSaver.Integration.App
|
||||
|
||||
`ShrinkDataSaver.Integration.App` 是 `ShrinkDataSaver` 与 `ShrinkApp.Core` 的桥接层。它的职责不是重新实现存档系统,而是把 `ShrinkDataSaver` 接到统一宿主链里,让项目在接入 `ShrinkApp` 时由宿主接管初始化顺序。
|
||||
|
||||
## 当前能力
|
||||
|
||||
- 提供原生 `ShrinkDataSaverAppComponent`,发布 `shrink.service.datasaver`
|
||||
- 在组件激活阶段调用 `ShrinkDataSaverRuntime.Initialize()`
|
||||
- ContextLoader 下向 `ShrinkApp.Services` 注册可逆的 `ShrinkDataSaverService` 门面
|
||||
- 区分 `IShrinkDataSaverReader` 与 `IShrinkDataSaverWriter`;默认上下文取得完整写能力
|
||||
- 支持以 `ShrinkDataSaverAccess.MetadataKey = ShrinkDataSaverAccess.ReadOnly` 的 intercept 向派生上下文只暴露读取能力,写接口和具体服务请求会被明确拒绝
|
||||
- `ShrinkDataSaverAppInstaller` 仅保留给 ClassicHost 兼容路径,已标记过时
|
||||
- 保留 `ShrinkDataSaverBootstrap` 的旧用法兼容;若宿主已接管,旧 bootstrap 会自动退出
|
||||
|
||||
## 只读上下文
|
||||
|
||||
```csharp
|
||||
var communityContext = ctx.Intercept(
|
||||
ShrinkDataSaverAppComponent.ServiceKey,
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
[ShrinkDataSaverAccess.MetadataKey] = ShrinkDataSaverAccess.ReadOnly
|
||||
});
|
||||
|
||||
var reader = communityContext.Get<IShrinkDataSaverReader>(
|
||||
ShrinkDataSaverAppComponent.ServiceKey);
|
||||
```
|
||||
|
||||
这是服务能力的最小化暴露:只约束通过 `ShrinkCtx.Get` 解析该键的调用方。它不会限制代码绕过上下文后直接访问静态 API,也不构成不可信模组沙箱。
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 85bcc5ac108273c41acdd89c5e2d0291
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 79b02908f32342443bd1a986d535a7d2
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "ShrinkDataSaver.Integration.App",
|
||||
"rootNamespace": "ShrinkDataSaver.Integration.App",
|
||||
"references": [
|
||||
"ShrinkDataSaver.Runtime",
|
||||
"ShrinkApp.Core.Runtime",
|
||||
"ShrinkContext.Core.Runtime",
|
||||
"UniTask"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b6cfc65e2b6dc8942866f0f374874564
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,65 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using ShrinkApp;
|
||||
using ShrinkContext;
|
||||
using ShrinkDataSaver;
|
||||
|
||||
namespace ShrinkDataSaver.Integration.App
|
||||
{
|
||||
/// <summary>
|
||||
/// ShrinkDataSaver 的原生 Cordis 组件(阶段 3):
|
||||
/// - 提供 <c>shrink.service.datasaver</c> 余效应键(门面服务);
|
||||
/// - 初始化为前向;停用时的终止动作为最终落盘设置(补偿式收尾:
|
||||
/// 存档写入本身跨会话持久,不可严格求逆,以 flush 保证一致性,见论文 6.1 扣留与补偿);
|
||||
/// - 与经典 ShrinkDataSaverAppInstaller(ModuleId "shrink.datasaver")二选一使用。
|
||||
/// </summary>
|
||||
public sealed class ShrinkDataSaverAppComponent : IShrinkComponent
|
||||
{
|
||||
public const string ServiceKey = "shrink.service.datasaver";
|
||||
public const string ModuleKey = "app.module.shrink.datasaver";
|
||||
|
||||
private static readonly string[] ProvideKeys = { ModuleKey, ServiceKey };
|
||||
|
||||
public string Name => "shrink.datasaver";
|
||||
|
||||
public IReadOnlyList<string> Inject => Array.Empty<string>();
|
||||
|
||||
public IReadOnlyList<string> Provide => ProvideKeys;
|
||||
|
||||
public async UniTask ApplyAsync(ShrinkCtx ctx, object? config)
|
||||
{
|
||||
// 编辑器/EditMode 环境下 DontDestroyOnLoad 会抛异常,驱动只在播放模式常驻
|
||||
ShrinkDataSaverRuntime.Initialize(new ShrinkDataSaverRuntimeConfig
|
||||
{
|
||||
DontDestroyOnLoadDriver = UnityEngine.Application.isPlaying
|
||||
});
|
||||
var facade = new ShrinkDataSaverService();
|
||||
ctx.Set(ServiceKey, facade, new ShrinkDataSaverAccessPolicy(facade));
|
||||
ctx.Set(ModuleKey, Name);
|
||||
|
||||
if (config is ShrinkAppServices appServices)
|
||||
{
|
||||
appServices.Register(facade);
|
||||
ctx.EffectInverse(() =>
|
||||
{
|
||||
appServices.TryUnregister(facade);
|
||||
return UniTask.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.EffectAsync(async () =>
|
||||
{
|
||||
await UniTask.CompletedTask;
|
||||
return () =>
|
||||
{
|
||||
// 终止动作(触发式):停用时把防抖中的设置写盘。
|
||||
// 磁盘 IO 在线程池完成,不阻塞停用链;未初始化环境安全跳过(SaveAsync 守卫)。
|
||||
ShrinkSettings.SaveAsync().Forget();
|
||||
return UniTask.CompletedTask;
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0ce2260a6cfecb349abe6119fdf37c12
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using ShrinkApp;
|
||||
using ShrinkContext;
|
||||
|
||||
namespace ShrinkDataSaver.Integration.App
|
||||
{
|
||||
[ShrinkAppModuleInstaller]
|
||||
[Obsolete("ClassicHost compatibility only. ContextLoader projects should use ShrinkDataSaverAppComponent from a composition root.")]
|
||||
public sealed class ShrinkDataSaverAppInstaller : IShrinkAppModuleInstaller
|
||||
{
|
||||
public string ModuleId => "shrink.datasaver";
|
||||
public int Order => -2000;
|
||||
public System.Collections.Generic.IReadOnlyList<string> DependsOn => Array.Empty<string>();
|
||||
|
||||
public void RegisterServices(ShrinkAppContext context)
|
||||
{
|
||||
context.Services.Register(new ShrinkDataSaverService());
|
||||
}
|
||||
|
||||
public UniTask InitializeAsync(ShrinkAppContext context)
|
||||
{
|
||||
ShrinkDataSaverRuntime.Initialize();
|
||||
return UniTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>不产生存档写入的读取能力。</summary>
|
||||
public interface IShrinkDataSaverReader
|
||||
{
|
||||
int LoadedSlot { get; }
|
||||
int GetRecentSlotIndex();
|
||||
UniTask<int> GetRecommendedContinueSlotAsync();
|
||||
UniTask<SaveMeta[]> GetAllMetaAsync();
|
||||
}
|
||||
|
||||
/// <summary>完整存档能力;社区模组可通过 intercept 被降权为仅 <see cref="IShrinkDataSaverReader"/>。</summary>
|
||||
public interface IShrinkDataSaverWriter : IShrinkDataSaverReader
|
||||
{
|
||||
UniTask SaveSlotAsync(int slotIndex, SaveOptions? options = null);
|
||||
UniTask LoadSlotAsync(int slotIndex, string? decryptionKey = null);
|
||||
}
|
||||
|
||||
public static class ShrinkDataSaverAccess
|
||||
{
|
||||
public const string MetadataKey = "access";
|
||||
public const string ReadOnly = "read-only";
|
||||
}
|
||||
|
||||
public sealed class ShrinkDataSaverService : IShrinkDataSaverWriter
|
||||
{
|
||||
public int LoadedSlot => ShrinkSave.LoadedSlot;
|
||||
public int GetRecentSlotIndex() => ShrinkSave.GetRecentSlotIndex();
|
||||
public UniTask<int> GetRecommendedContinueSlotAsync() => ShrinkSave.GetRecommendedContinueSlotAsync();
|
||||
public UniTask<SaveMeta[]> GetAllMetaAsync() => ShrinkSave.GetAllMetaAsync();
|
||||
public UniTask SaveSlotAsync(int slotIndex, SaveOptions? options = null) => ShrinkSave.SaveSlotAsync(slotIndex, options);
|
||||
public UniTask LoadSlotAsync(int slotIndex, string? decryptionKey = null) => ShrinkSave.LoadSlotAsync(slotIndex, decryptionKey);
|
||||
}
|
||||
|
||||
internal sealed class ShrinkDataSaverReadOnlyService : IShrinkDataSaverReader
|
||||
{
|
||||
private readonly IShrinkDataSaverReader _inner;
|
||||
|
||||
public ShrinkDataSaverReadOnlyService(IShrinkDataSaverReader inner)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
}
|
||||
|
||||
public int LoadedSlot => _inner.LoadedSlot;
|
||||
public int GetRecentSlotIndex() => _inner.GetRecentSlotIndex();
|
||||
public UniTask<int> GetRecommendedContinueSlotAsync() => _inner.GetRecommendedContinueSlotAsync();
|
||||
public UniTask<SaveMeta[]> GetAllMetaAsync() => _inner.GetAllMetaAsync();
|
||||
}
|
||||
|
||||
/// <summary>DataSaver provider 侧的访问介导;仅约束经 ShrinkCtx.Get 取得的能力。</summary>
|
||||
internal sealed class ShrinkDataSaverAccessPolicy : IShrinkCoeffectAccessPolicy
|
||||
{
|
||||
private readonly ShrinkDataSaverReadOnlyService _readOnly;
|
||||
|
||||
public ShrinkDataSaverAccessPolicy(ShrinkDataSaverService full)
|
||||
{
|
||||
_readOnly = new ShrinkDataSaverReadOnlyService(
|
||||
full ?? throw new ArgumentNullException(nameof(full)));
|
||||
}
|
||||
|
||||
public object? Resolve(ShrinkCoeffectAccessContext context, object? value)
|
||||
{
|
||||
if (!context.TryGetMetadata<string>(ShrinkDataSaverAccess.MetadataKey, out var access) ||
|
||||
!string.Equals(access, ShrinkDataSaverAccess.ReadOnly, StringComparison.Ordinal))
|
||||
return value;
|
||||
|
||||
if (typeof(IShrinkDataSaverWriter).IsAssignableFrom(context.RequestedType) ||
|
||||
context.RequestedType == typeof(ShrinkDataSaverService))
|
||||
{
|
||||
throw new ShrinkCoeffectAccessDeniedException(context.Key,
|
||||
"the current context grants read-only DataSaver access");
|
||||
}
|
||||
|
||||
if (context.RequestedType == typeof(object) || context.RequestedType.IsInstanceOfType(_readOnly))
|
||||
return _readOnly;
|
||||
|
||||
throw new ShrinkCoeffectAccessDeniedException(context.Key,
|
||||
$"read-only DataSaver view cannot satisfy requested type '{context.RequestedType.FullName}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9eea84dd572634c438c99aef69c51d9f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "com.cneicy.shrink-datasaver-integration-app",
|
||||
"version": "0.1.0",
|
||||
"displayName": "ShrinkDataSaver - App Integration",
|
||||
"description": "ShrinkDataSaver 与 ShrinkApp 的桥接层,提供宿主接管初始化与服务注册。",
|
||||
"unity": "2022.3",
|
||||
"dependencies": {
|
||||
"com.cneicy.shrink-datasaver": "2.2.0",
|
||||
"com.cneicy.shrink-app-core": "0.1.0",
|
||||
"com.cysharp.unitask": "2.5.10",
|
||||
"com.cneicy.shrink-context-core": "0.1.0"
|
||||
},
|
||||
"keywords": [
|
||||
"save",
|
||||
"integration",
|
||||
"app",
|
||||
"bootstrap"
|
||||
],
|
||||
"author": {
|
||||
"name": "cneicy",
|
||||
"url": "https://github.com/cneicy"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8358963c0e3299e478b163272aa6b09b
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -72,6 +72,8 @@
|
||||
- 是否自动监听目录变化并协调外部 DLL revision 变化
|
||||
- `externalModsReloadDelaySeconds`
|
||||
- 文件变更后延迟多少秒再尝试热加载
|
||||
- `externalAssemblyRevisionSoftLimit`
|
||||
- 外部 DLL 常驻 revision 的软阈值;达到阈值时提示执行 Domain Reload 或重启进程,默认 `16`
|
||||
- `enableHarmonyPatching`
|
||||
- 是否启用 Harmony 自动补丁
|
||||
- `enableNetworkSync`
|
||||
@@ -209,6 +211,16 @@ ShrinkModLoader.LoadNewExternalMods();
|
||||
|
||||
如果 `watchExternalModsDirectory = true`,框架还会监听新增、修改、删除、重命名事件,经过主线程 debouncer 后提交一次完整组合;同一 burst 内的中间坏文件不会覆盖当前有效 revision。
|
||||
|
||||
### 常驻 revision 诊断
|
||||
|
||||
Unity/Mono 不能从当前 AppDomain 单独卸载已载入程序集。框架保留当前 revision 的生效语义,同时通过以下接口暴露实际常驻情况:
|
||||
|
||||
```csharp
|
||||
var snapshot = ShrinkModDiagnostics.CaptureExternalAssemblies(settings);
|
||||
```
|
||||
|
||||
快照包含当前与历史 revision、来源路径、程序集名、SHA-256 revision、载入字节数、常驻数量和软阈值状态。失败 revision 可以成为已载入的历史程序集,但不会成为 current;达到软阈值只告警,不伪造卸载行为。
|
||||
|
||||
## Harmony 热补丁
|
||||
|
||||
如果运行环境里存在 `0Harmony`,ContextHost 会把每个模组的补丁租约作为可逆效应管理:激活时调用
|
||||
@@ -649,7 +661,7 @@ public sealed class DemoFullMod : ShrinkModBase
|
||||
- 模组 ID 必须全局唯一
|
||||
- 必选依赖缺失或版本不满足时,装载直接失败
|
||||
- 检测到循环依赖时,装载直接失败
|
||||
- 外部 DLL 只支持热添加,不支持热卸载
|
||||
- 外部 DLL 支持当前组合的添加、替换与删除;已载入的程序集 revision 仍常驻,回滚和卸载单位是模组 fiber 与其效应
|
||||
|
||||
## 关键文件
|
||||
|
||||
|
||||
@@ -56,6 +56,9 @@ namespace ShrinkModFramework
|
||||
public string externalModsFolderName = "Mods";
|
||||
public bool watchExternalModsDirectory = true;
|
||||
public float externalModsReloadDelaySeconds = 0.5f;
|
||||
[Min(1)]
|
||||
[Tooltip("Mono 下外部程序集 revision 会常驻;历史数量达到该软阈值后提示 Domain Reload/重启。")]
|
||||
public int externalAssemblyRevisionSoftLimit = 16;
|
||||
|
||||
[Header("Harmony")]
|
||||
public bool enableHarmonyPatching = true;
|
||||
|
||||
+52
-6
@@ -15,6 +15,7 @@ namespace ShrinkModFramework
|
||||
public string Path;
|
||||
public string Revision;
|
||||
public Assembly Assembly;
|
||||
public long LoadedBytes;
|
||||
}
|
||||
|
||||
internal sealed class RevisionState
|
||||
@@ -27,10 +28,11 @@ namespace ShrinkModFramework
|
||||
|
||||
private static readonly Dictionary<string, ExternalAssemblyRevision> CurrentAssemblyRevisions =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly HashSet<Assembly> ExternalAssemblyHistory = new();
|
||||
private static readonly Dictionary<Assembly, ExternalAssemblyRevision> ExternalAssemblyHistory = new();
|
||||
private static readonly Dictionary<string, string> KnownAssemblyFiles = new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly object ResolveLock = new();
|
||||
private static bool _resolveRegistered;
|
||||
private static int _lastWarnedResidentCount;
|
||||
|
||||
public static IReadOnlyList<Assembly> LoadExternalAssemblies(ShrinkModFrameworkSettings settings, bool verboseLogging)
|
||||
{
|
||||
@@ -82,18 +84,22 @@ namespace ShrinkModFramework
|
||||
}
|
||||
|
||||
var pdbPath = Path.ChangeExtension(dllPath, ".pdb");
|
||||
var assembly = File.Exists(pdbPath)
|
||||
? Assembly.Load(bytes, File.ReadAllBytes(pdbPath))
|
||||
byte[] pdbBytes = null;
|
||||
if (File.Exists(pdbPath))
|
||||
pdbBytes = File.ReadAllBytes(pdbPath);
|
||||
var assembly = pdbBytes != null
|
||||
? Assembly.Load(bytes, pdbBytes)
|
||||
: Assembly.Load(bytes);
|
||||
|
||||
var loadedRevision = new ExternalAssemblyRevision
|
||||
{
|
||||
Path = dllPath,
|
||||
Revision = revision,
|
||||
Assembly = assembly
|
||||
Assembly = assembly,
|
||||
LoadedBytes = bytes.LongLength + (pdbBytes?.LongLength ?? 0L)
|
||||
};
|
||||
CurrentAssemblyRevisions[dllPath] = loadedRevision;
|
||||
ExternalAssemblyHistory.Add(assembly);
|
||||
ExternalAssemblyHistory[assembly] = loadedRevision;
|
||||
|
||||
if (verboseLogging)
|
||||
Debug.Log($"[ShrinkModFramework] 已加载外部模组程序集:{assembly.GetName().Name} ({revision[..12]})");
|
||||
@@ -104,6 +110,8 @@ namespace ShrinkModFramework
|
||||
}
|
||||
}
|
||||
|
||||
WarnIfResidentLimitExceeded(settings);
|
||||
|
||||
foreach (var missingPath in CurrentAssemblyRevisions.Keys
|
||||
.Where(path => !presentPaths.Contains(path))
|
||||
.ToArray())
|
||||
@@ -124,7 +132,30 @@ namespace ShrinkModFramework
|
||||
}
|
||||
|
||||
internal static bool IsExternalAssembly(Assembly assembly) =>
|
||||
assembly != null && ExternalAssemblyHistory.Contains(assembly);
|
||||
assembly != null && ExternalAssemblyHistory.ContainsKey(assembly);
|
||||
|
||||
internal static ShrinkExternalAssemblyDiagnostic CaptureDiagnostic(int softLimit)
|
||||
{
|
||||
var normalizedLimit = Math.Max(1, softLimit);
|
||||
var currentAssemblies = new HashSet<Assembly>(
|
||||
CurrentAssemblyRevisions.Values.Select(item => item.Assembly));
|
||||
var revisions = ExternalAssemblyHistory.Values
|
||||
.OrderBy(item => item.Path, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(item => item.Revision, StringComparer.Ordinal)
|
||||
.Select(item => new ShrinkExternalAssemblyRevisionDiagnostic(
|
||||
item.Path,
|
||||
item.Revision,
|
||||
item.Assembly.GetName().Name ?? string.Empty,
|
||||
item.LoadedBytes,
|
||||
currentAssemblies.Contains(item.Assembly)))
|
||||
.ToArray();
|
||||
|
||||
return new ShrinkExternalAssemblyDiagnostic(
|
||||
CurrentAssemblyRevisions.Count,
|
||||
revisions,
|
||||
revisions.Sum(item => item.LoadedBytes),
|
||||
normalizedLimit);
|
||||
}
|
||||
|
||||
internal static RevisionState CaptureState()
|
||||
{
|
||||
@@ -193,6 +224,21 @@ namespace ShrinkModFramework
|
||||
{
|
||||
ResetForTesting();
|
||||
ExternalAssemblyHistory.Clear();
|
||||
_lastWarnedResidentCount = 0;
|
||||
}
|
||||
|
||||
private static void WarnIfResidentLimitExceeded(ShrinkModFrameworkSettings settings)
|
||||
{
|
||||
var limit = Math.Max(1, settings != null ? settings.externalAssemblyRevisionSoftLimit : 16);
|
||||
var residentCount = ExternalAssemblyHistory.Count;
|
||||
if (residentCount < limit || residentCount == _lastWarnedResidentCount)
|
||||
return;
|
||||
|
||||
_lastWarnedResidentCount = residentCount;
|
||||
var bytes = ExternalAssemblyHistory.Values.Sum(item => item.LoadedBytes);
|
||||
Debug.LogWarning(
|
||||
$"[ShrinkModFramework] 外部 DLL 常驻 revision 已达到 {residentCount} 个(载入文件约 {bytes} bytes," +
|
||||
$"软阈值 {limit})。Mono 无法卸载这些 Assembly;建议在维护窗口执行 Domain Reload 或重启进程。");
|
||||
}
|
||||
|
||||
private static string ComputeSha256(byte[] bytes)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ShrinkModFramework
|
||||
{
|
||||
public sealed class ShrinkExternalAssemblyRevisionDiagnostic
|
||||
{
|
||||
internal ShrinkExternalAssemblyRevisionDiagnostic(string path, string revision, string assemblyName,
|
||||
long loadedBytes, bool isCurrent)
|
||||
{
|
||||
Path = path;
|
||||
Revision = revision;
|
||||
AssemblyName = assemblyName;
|
||||
LoadedBytes = loadedBytes;
|
||||
IsCurrent = isCurrent;
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
public string Revision { get; }
|
||||
public string AssemblyName { get; }
|
||||
public long LoadedBytes { get; }
|
||||
public bool IsCurrent { get; }
|
||||
}
|
||||
|
||||
/// <summary>Mono 外部程序集的 current 与常驻历史快照;LoadedBytes 仅统计载入 DLL/PDB 文件大小。</summary>
|
||||
public sealed class ShrinkExternalAssemblyDiagnostic
|
||||
{
|
||||
internal ShrinkExternalAssemblyDiagnostic(int currentRevisionCount,
|
||||
IReadOnlyList<ShrinkExternalAssemblyRevisionDiagnostic> residentRevisions,
|
||||
long estimatedResidentBytes, int softLimit)
|
||||
{
|
||||
CurrentRevisionCount = currentRevisionCount;
|
||||
ResidentRevisions = residentRevisions;
|
||||
EstimatedResidentBytes = estimatedResidentBytes;
|
||||
SoftLimit = softLimit;
|
||||
}
|
||||
|
||||
public int CurrentRevisionCount { get; }
|
||||
public IReadOnlyList<ShrinkExternalAssemblyRevisionDiagnostic> ResidentRevisions { get; }
|
||||
public int ResidentRevisionCount => ResidentRevisions.Count;
|
||||
public long EstimatedResidentBytes { get; }
|
||||
public int SoftLimit { get; }
|
||||
public bool IsSoftLimitExceeded => ResidentRevisionCount >= SoftLimit;
|
||||
}
|
||||
|
||||
public static class ShrinkModDiagnostics
|
||||
{
|
||||
public static ShrinkExternalAssemblyDiagnostic CaptureExternalAssemblies(
|
||||
ShrinkModFrameworkSettings settings = null)
|
||||
{
|
||||
if (settings == null)
|
||||
settings = ShrinkModFrameworkSettings.Instance;
|
||||
return ShrinkExternalModAssemblyLoader.CaptureDiagnostic(
|
||||
settings != null ? settings.externalAssemblyRevisionSoftLimit : 16);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 20342cb92d99bc34ca99a41312b5a95d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -3,6 +3,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
@@ -241,6 +242,16 @@ namespace ShrinkModFramework.Tests
|
||||
CopyFixture("ExternalFixture.Mod.V3.dll.bytes", target);
|
||||
ShrinkModLoader.LoadNewExternalMods(settings);
|
||||
AssertExternalFixture("3.0.0", "v3");
|
||||
|
||||
settings.externalAssemblyRevisionSoftLimit = 2;
|
||||
var diagnostics = ShrinkModDiagnostics.CaptureExternalAssemblies(settings);
|
||||
Assert.AreEqual(1, diagnostics.CurrentRevisionCount,
|
||||
"同一路径只有最终有效 revision 是 current");
|
||||
Assert.GreaterOrEqual(diagnostics.ResidentRevisionCount, 3,
|
||||
"v1、失败但已加载的 v2、v3 Assembly 在 Mono 下都会常驻");
|
||||
Assert.Greater(diagnostics.EstimatedResidentBytes, 0L);
|
||||
Assert.IsTrue(diagnostics.IsSoftLimitExceeded);
|
||||
Assert.AreEqual(1, diagnostics.ResidentRevisions.Count(item => item.IsCurrent));
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
+59
-4
@@ -1,6 +1,6 @@
|
||||
# ShrinkSDK × Cordis 组织形式改造方案
|
||||
|
||||
> 生成日期:2026-08-16
|
||||
> 生成日期:2026-08-16;独立重读与阶段 5 路线更新:2026-08-17
|
||||
> 参考文献:《Cordis: A Programming Paradigm for Spatiotemporal Composability》(北京大学 / DeepSeek-AI,88 页,`C:\Users\im\Downloads\Cordis_paper_zh-CN.pdf`)
|
||||
> 本文档回答一个问题:**如果按 Cordis 的"时空可组合性"范式重组 ShrinkSDK,目标形态是什么、差距在哪、如何分阶段走过去。**
|
||||
> 现状基线见仓库根目录 `DESIGN.md`。
|
||||
@@ -70,6 +70,19 @@
|
||||
8. **跨领域集成组件不是过渡瑕疵,而是论文 §6.5 的正式答案**。Command-Network、模块-EventBus 这类双向/循环关系应由一个注入双方服务键的薄组件承载,核心包保持互不依赖。真正应淘汰的是隐式反射接桥和安装器包装,不是所有 Integration 包。
|
||||
9. **字符串键仍有碰撞与接口漂移风险**。当前先使用命名空间化键;后续应把包版本约束、结构兼容检查或强类型生成键纳入加载器边界。
|
||||
|
||||
### 1.6 对 ShrinkSDK 的工程判断
|
||||
|
||||
综合论文第 3~6 节与当前实现,我对 Cordis 在 ShrinkSDK 中的定位是:
|
||||
|
||||
1. **它应当是生命周期与组合语义的唯一地基,不是新的业务框架。** EventBus、DataSaver、Network、Command、Tutorial 仍然保留自己的领域 API;Cordis 只负责回答“谁拥有这些副作用、依赖变化时谁该运行、组件退出时如何恢复”。后续不能再新增第三套启动/卸载模型。
|
||||
2. **阶段 0~4 已经覆盖论文最关键的正确性路径。** 当前 `ShrinkContextRuntime` 已具备 provider uid、committed view、惯性转换、部分回滚和 drain-before-inverse;`ShrinkModContextHost` 已把外部 DLL 的组件源、revision 缓存与旧组合恢复纳入同一事务。接下来主要是把这些保证变得可约束、可诊断、可运营,而不是重新实现 fiber。
|
||||
3. **当前最大语义缺口是“声明没有被完全强制”。** `IShrinkComponent.Provide` 目前仍主要依赖作者纪律,运行时没有拒绝组件写入未声明键;字符串键也只提供名义连接,没有类型和版本兼容保证。若不先固定访问契约,intercept 只能成为一层不可靠的包装。
|
||||
4. **当前最大工程缺口是“发生问题时看不见”。** fiber 已有状态和 `LastError`,但缺少稳定的运行时快照、事务阶段、依赖等待链和 revision 常驻统计。动态组合系统一旦发生等待、回滚或程序集常驻增长,必须能从诊断面直接回答原因。
|
||||
5. **intercept 是访问介导,不是安全沙箱。** 它适合表达“社区模组只能只读 DataSaver”“某组件不能访问某类网络能力”等宿主可控策略;但外部 DLL 仍可绕开 ctx 直接调用 CLR/Unity API。不可信代码必须另用进程、WebAssembly 或其他执行隔离,不能把语言层访问控制写成安全边界。
|
||||
6. **程序集不可卸载必须转化为容量策略。** Mono 下 revision 替换只能撤回实例和效应,旧 `Assembly` 仍然常驻。正确做法不是伪装成完全卸载,而是统计历史 revision、估算常驻量、设阈值并给出重启建议。
|
||||
|
||||
因此,阶段 5 的顺序应为:先收紧访问契约和诊断,再实现 isolate/intercept,之后补程序集常驻策略和配置/调试工具。这样后续能力建立在可验证的边界上,而不是继续扩大隐式行为。
|
||||
|
||||
---
|
||||
|
||||
## 2. 概念对照:Cordis ↔ ShrinkSDK 现状
|
||||
@@ -247,8 +260,50 @@ await loader.ApplyConfigAsync(configTree); // 增量协调:diff → reload/u
|
||||
- 真实 fixture 验收覆盖:编译 DLL `valid → changed(fail) → restore`、损坏字节不污染当前 revision、有效 revision 恢复、watcher burst 去抖、删除卸载、依赖 DLL 删除/恢复。`ShrinkModFramework.Tests` **9/9**,全仓 EditMode **180/180**。
|
||||
- 真实 Starter PlayMode 确认 `modCordis=true`、`modLoaded=true`、宿主 7 个模块 active 且无错误。Unity 程序集不可卸载,因此回滚单位仍是组件实例与效应,而不是类型本身。
|
||||
|
||||
### 阶段 5:隔离与拦截(可选增强)
|
||||
- 测试域 isolate(同一份组件在多个隔离域各跑一份);intercept 做访问控制(社区模组只读 DataSaver)。
|
||||
### 阶段 5:生产化边界、隔离与拦截(进行中,2026-08-17 启动)
|
||||
|
||||
#### 阶段 5A:访问契约、诊断与通知索引 ✅ 首批完成(2026-08-17)
|
||||
|
||||
- 运行时强制组件只能向 `Provide` 声明的键写入;根上下文的 ambient 绑定保留为显式系统边界。
|
||||
- 提供稳定的 runtime/fiber 诊断快照:uid、组件名、状态、target、committed provider、realm、是否退役、是否转换中、最近错误。
|
||||
- 提供依赖等待关系与加载器事务诊断,使失败能定位到条目、fiber、阶段和恢复结果。
|
||||
- 将 notify 从遍历全部 fiber 改为 `key → inject fibers` 倒排索引;realm 仍在触发时精确过滤,复杂度由 O(all fibers) 收敛为 O(affected-by-key)。
|
||||
- 强类型/版本键以新增契约逐步引入,不在本阶段强行改写全部现有字符串键调用点。
|
||||
|
||||
**验收:** 未声明供给在 apply 阶段被拒绝并完整回滚;诊断快照能解释 Active/Waiting/Failed/Unloading;索引不会遗漏不同 realm 下的合法依赖者;既有生命周期测试全部保持通过。
|
||||
|
||||
**已落地:** `Provide` 写入约束、`ShrinkKey<T>` 版本键、runtime/fiber/依赖/notify 诊断快照、loader 事务与恢复结果、`key → inject fibers` 倒排索引均已实现。target 仍是 provider uid 视图,但现在保留 inject 声明顺序和重复键,不再退化成无序 provider 集合。
|
||||
|
||||
#### 阶段 5B:isolate 隔离验证与 intercept ✅ 首批完成(2026-08-17)
|
||||
|
||||
- loader 支持隔离域条目;同一组件在多个 realm 中独立运行,替换其中一个 realm 的提供者不扰动另一个 realm。
|
||||
- 补 `ctx.intercept(key, metadata)` 的派生上下文与元数据合并;策略改变依赖的使用方式,不改变满足关系,也不因策略变化触发 fiber 重载。
|
||||
- 访问介导使用明确的服务策略/包装接口,不把 Harmony 或通用反射 AOP 当成默认实现。
|
||||
- 以“社区模组只读 DataSaver”为真实验收:读取允许,写入拒绝,核心组件仍保留完整能力。
|
||||
|
||||
**验收:** 多 realm 激活/替换/卸载互不串扰;intercept 更新不改变 provider uid 和 fiber generation;未声明访问、未激活访问与策略拒绝三类错误可以区分;文档明确其不是不可信代码沙箱。
|
||||
|
||||
**已落地:** loader 条目可携带 isolate 与 intercept;intercept 元数据按上下文链合并并在条目原位更新,不触发 fiber 重载。DataSaver 提供 `Reader / Writer` 能力拆分,社区上下文可取得只读包装,写接口与具体服务请求被策略拒绝。当前 isolate 条目变化仍通过重建该条目生效;运行中 fiber 原位迁移 realm 未实现,也不计入本批完成项。
|
||||
|
||||
#### 阶段 5C:外部 DLL 常驻 revision 策略 ✅ 首批完成(2026-08-17)
|
||||
|
||||
- 暴露当前 revision、历史程序集数量、来源路径与累计载入字节。
|
||||
- 增加软阈值和明确告警;超过阈值时建议 Domain Reload/进程重启,不尝试在 Mono 上伪造程序集卸载。
|
||||
- 长时间回归覆盖连续有效替换、失败恢复、损坏 DLL、删除/恢复与历史 revision 增长。
|
||||
|
||||
**验收:** 每次替换后当前 revision 与生效组件一致;失败 revision 不成为 current;常驻增长可查询、可告警且不影响旧组合恢复。
|
||||
|
||||
**已落地:** `ShrinkModDiagnostics.CaptureExternalAssemblies` 暴露 current/history、路径、程序集、revision、载入字节与软阈值状态;达到 `externalAssemblyRevisionSoftLimit` 时只给出 Domain Reload/进程重启建议。真实 DLL fixture 覆盖有效、失败、再有效三个 revision,确认失败程序集可以常驻但不会成为 current。
|
||||
|
||||
#### 阶段 5D:配置与调试体验
|
||||
|
||||
- 将代码构建的默认组合逐步映射到 ScriptableObject/JSON 条目,并保持代码目录作为组件工厂来源。
|
||||
- 提供 Editor/运行时调试面,查看 active/waiting/failed fiber、provider target、最近事务和常驻程序集统计。
|
||||
- 建立 notify、重载延迟、失败恢复和常驻内存的基准,避免仅以功能测试替代容量判断。
|
||||
|
||||
**阶段 5 明确非目标:** 不在本阶段实现不可信 DLL 沙箱;不承诺已发出的网络数据或外部文件写入可以真正撤回;不删除 ClassicHost 兼容面,除非其调用方已完成独立迁移验证。
|
||||
|
||||
**2026-08-17 验证基线:** Unity 编译 **0 error / 0 warning**;全仓 EditMode **189/189**;PlayMode Test Runner 当前没有测试项,因此另行启动真实 `Assets/Scenes/ShrinkAppEntry.unity` 验收:7 个模块全部 Active、0 Waiting、10 个绑定、3 个 notify 索引键,启动与退出过程无控制台 error。阶段 5D 的配置资产、调试面与容量基准尚未开始。
|
||||
|
||||
---
|
||||
|
||||
@@ -268,4 +323,4 @@ await loader.ApplyConfigAsync(configTree); // 增量协调:diff → reload/u
|
||||
- ShrinkSDK 与 Cordis 的**分层直觉一致**(核心库 / 编排层 / 领域层),差距集中在**核心库的两个原语缺失**:统一可逆效应追踪(时间)与响应式依赖解析(空间)。
|
||||
- 改造的本质不是重写功能模块,而是**把 ShrinkApp 的"一次性安装器"升级为 Cordis 的"持续协调的组件加载器"**,并让七个 Integration 桥接包退化为薄适配直至消失。
|
||||
- Unity 的"程序集不可卸载"不阻塞该范式——可回滚单位是组件实例与效应,而非类型;这与 ModFramework 既有边界声明兼容。
|
||||
- 阶段 0-4 已完成到 Basic Starter 的默认运行主路径与 ModFramework 事务性 HMR;后续若继续推进,应进入阶段 5 的 isolate/intercept 能力或围绕程序集常驻做容量与运维策略,不再新增第三套生命周期。
|
||||
- 阶段 0-4 已完成到 Basic Starter 的默认运行主路径与 ModFramework 事务性 HMR;阶段 5 已完成访问契约、诊断/notify 索引、isolate/intercept 首批能力和程序集常驻策略,下一步是阶段 5D 的配置、调试面与容量基准,不再新增第三套生命周期。
|
||||
|
||||
Reference in New Issue
Block a user