From 8738e633eec2dc9f30212418cbbe253166e45007 Mon Sep 17 00:00:00 2001 From: cneicy Date: Mon, 17 Aug 2026 01:09:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(cordis):=20=E5=AE=8C=E6=88=90=E9=98=B6?= =?UTF-8?q?=E6=AE=B55=E8=AE=BF=E9=97=AE=E4=BB=8B=E5=AF=BC=E4=B8=8E?= =?UTF-8?q?=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 + .../Tests/IntegrationComponentsTests.cs | 52 +++++ Assets/Modules/ShrinkContext.Core/README.md | 31 +-- .../Runtime/Coeffects/ShrinkCoeffectTypes.cs | 99 ++++++++- .../Runtime/Context/ShrinkCtx.cs | 111 +++++++++- .../Runtime/Fibers/ShrinkFiber.cs | 2 + .../Runtime/Loader/ShrinkLoader.cs | 196 +++++++++++++++++- .../Runtime/ShrinkContextDefaults.cs | 2 +- .../Runtime/ShrinkContextDiagnostics.cs | 112 ++++++++++ .../Runtime/ShrinkContextDiagnostics.cs.meta | 11 + .../Runtime/ShrinkContextRuntime.cs | 187 +++++++++++++++-- .../Tests/CoeffectAccessTests.cs | 34 +++ .../Tests/FiberLifecycleTests.cs | 52 +++++ .../ShrinkContext.Core/Tests/LoaderTests.cs | 85 ++++++++ .../Tests/TestComponents.cs | 75 +++++++ .../CHANGELOG.md | 18 ++ .../CHANGELOG.md.meta | 7 + .../ShrinkDataSaver.Integration.App/README.md | 29 +++ .../README.md.meta | 7 + .../Runtime.meta | 8 + .../ShrinkDataSaver.Integration.App.asmdef | 18 ++ ...hrinkDataSaver.Integration.App.asmdef.meta | 7 + .../Runtime/ShrinkDataSaverAppComponent.cs | 65 ++++++ .../ShrinkDataSaverAppComponent.cs.meta | 11 + .../Runtime/ShrinkDataSaverAppInstaller.cs | 108 ++++++++++ .../ShrinkDataSaverAppInstaller.cs.meta | 11 + .../package.json | 23 ++ .../package.json.meta | 7 + Assets/Modules/ShrinkModFramework/README.md | 14 +- .../Bootstrap/ShrinkModFrameworkSettings.cs | 3 + .../ShrinkExternalModAssemblyLoader.cs | 58 +++++- .../Runtime/Loading/ShrinkModDiagnostics.cs | 57 +++++ .../Loading/ShrinkModDiagnostics.cs.meta | 11 + .../Tests/ShrinkModContextHostTests.cs | 11 + CORDIS_MIGRATION.md | 63 +++++- 35 files changed, 1540 insertions(+), 50 deletions(-) create mode 100644 Assets/Modules/ShrinkContext.Core/Runtime/ShrinkContextDiagnostics.cs create mode 100644 Assets/Modules/ShrinkContext.Core/Runtime/ShrinkContextDiagnostics.cs.meta create mode 100644 Assets/Modules/ShrinkDataSaver.Integration.App/CHANGELOG.md create mode 100644 Assets/Modules/ShrinkDataSaver.Integration.App/CHANGELOG.md.meta create mode 100644 Assets/Modules/ShrinkDataSaver.Integration.App/README.md create mode 100644 Assets/Modules/ShrinkDataSaver.Integration.App/README.md.meta create mode 100644 Assets/Modules/ShrinkDataSaver.Integration.App/Runtime.meta create mode 100644 Assets/Modules/ShrinkDataSaver.Integration.App/Runtime/ShrinkDataSaver.Integration.App.asmdef create mode 100644 Assets/Modules/ShrinkDataSaver.Integration.App/Runtime/ShrinkDataSaver.Integration.App.asmdef.meta create mode 100644 Assets/Modules/ShrinkDataSaver.Integration.App/Runtime/ShrinkDataSaverAppComponent.cs create mode 100644 Assets/Modules/ShrinkDataSaver.Integration.App/Runtime/ShrinkDataSaverAppComponent.cs.meta create mode 100644 Assets/Modules/ShrinkDataSaver.Integration.App/Runtime/ShrinkDataSaverAppInstaller.cs create mode 100644 Assets/Modules/ShrinkDataSaver.Integration.App/Runtime/ShrinkDataSaverAppInstaller.cs.meta create mode 100644 Assets/Modules/ShrinkDataSaver.Integration.App/package.json create mode 100644 Assets/Modules/ShrinkDataSaver.Integration.App/package.json.meta create mode 100644 Assets/Modules/ShrinkModFramework/Runtime/Loading/ShrinkModDiagnostics.cs create mode 100644 Assets/Modules/ShrinkModFramework/Runtime/Loading/ShrinkModDiagnostics.cs.meta diff --git a/.gitignore b/.gitignore index 1b12dc5..0b98399 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Assets/Modules/ShrinkCommand.Integration.Network/Tests/IntegrationComponentsTests.cs b/Assets/Modules/ShrinkCommand.Integration.Network/Tests/IntegrationComponentsTests.cs index ff041c7..df1664e 100644 --- a/Assets/Modules/ShrinkCommand.Integration.Network/Tests/IntegrationComponentsTests.cs +++ b/Assets/Modules/ShrinkCommand.Integration.Network/Tests/IntegrationComponentsTests.cs @@ -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(_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> + { + [ShrinkDataSaverAppComponent.ServiceKey] = new Dictionary + { + [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(() => consumerComponent.GetWriter()); + + Assert.IsTrue(_runtime.TryGetRaw(_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 Inject { get; } = new[] { ShrinkDataSaverAppComponent.ServiceKey }; + public IReadOnlyList Provide => Array.Empty(); + + public UniTask ApplyAsync(ShrinkCtx ctx, object? config) + { + _ctx = ctx; + return UniTask.CompletedTask; + } + + public IShrinkDataSaverReader GetReader() => Context.Get( + ShrinkDataSaverAppComponent.ServiceKey); + + public IShrinkDataSaverWriter GetWriter() => Context.Get( + ShrinkDataSaverAppComponent.ServiceKey); + + private ShrinkCtx Context => _ctx ?? throw new InvalidOperationException("Consumer is not active."); + } } } diff --git a/Assets/Modules/ShrinkContext.Core/README.md b/Assets/Modules/ShrinkContext.Core/README.md index 97536fb..2bd5bc8 100644 --- a/Assets/Modules/ShrinkContext.Core/README.md +++ b/Assets/Modules/ShrinkContext.Core/README.md @@ -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`(可逆,安装+通知 / 撤回+通知) | | `ctx.isolate(key, realm)` | `ShrinkCtx.Isolate`(派生子上下文,丢弃即恢复) | -| 代理介导访问(算法 6) | `ShrinkCtx.Get/TryGet`(沿纤程链解析已提交视图;未声明/未激活访问抛异常) | +| `ctx.intercept(key, metadata)` | `ShrinkCtx.Intercept`(派生访问元数据;provider 通过 `IShrinkCoeffectAccessPolicy` 返回受限视图或拒绝访问) | +| 代理介导访问(算法 6) | `ShrinkCtx.Get/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 调度仍由上层应用负责。 - 跨进程/独立服务器上下文 ## 约定 diff --git a/Assets/Modules/ShrinkContext.Core/Runtime/Coeffects/ShrinkCoeffectTypes.cs b/Assets/Modules/ShrinkContext.Core/Runtime/Coeffects/ShrinkCoeffectTypes.cs index 543e066..5904587 100644 --- a/Assets/Modules/ShrinkContext.Core/Runtime/Coeffects/ShrinkCoeffectTypes.cs +++ b/Assets/Modules/ShrinkContext.Core/Runtime/Coeffects/ShrinkCoeffectTypes.cs @@ -1,21 +1,92 @@ #nullable enable using System; +using System.Collections.Generic; namespace ShrinkContext { /// 共享存储中的一条依赖绑定:键解析到 realm 后对应的值与提供者纤程。 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; } + } + + /// + /// 带命名空间与主版本的类型化余效应键。现有字符串声明可继续使用 , + /// Set/Get 重载则在调用点固定值类型,逐步收口字符串键的碰撞与接口漂移。 + /// + public sealed class ShrinkKey + { + 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; + } + + /// 依赖值被读取时的访问上下文;metadata 来自访问方上下文上的 intercept 合并结果。 + public sealed class ShrinkCoeffectAccessContext + { + internal ShrinkCoeffectAccessContext(string key, Type requestedType, ShrinkFiber? consumer, + IReadOnlyDictionary metadata) + { + Key = key; + RequestedType = requestedType; + Consumer = consumer; + Metadata = metadata; + } + + public string Key { get; } + public Type RequestedType { get; } + public ShrinkFiber? Consumer { get; } + public IReadOnlyDictionary Metadata { get; } + + public bool TryGetMetadata(string name, out T value) + { + if (Metadata.TryGetValue(name, out var raw) && raw is T typed) + { + value = typed; + return true; + } + + value = default!; + return false; + } + } + + /// + /// provider 侧访问策略。它可以依据 intercept metadata 返回降权包装或拒绝访问, + /// 但不会改变依赖满足关系,也不会构成不可信代码沙箱。 + /// + public interface IShrinkCoeffectAccessPolicy + { + object? Resolve(ShrinkCoeffectAccessContext context, object? value); } /// 访问了纤程链上没有任何一方声明的依赖键(论文算法 6 的 UNDECLARED_ACCESS)。 @@ -42,6 +113,32 @@ namespace ShrinkContext public string Key { get; } } + /// 组件写入了未在 Provide 中声明的键。 + 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; } + } + + /// intercept 访问策略拒绝了本次依赖读取。 + public sealed class ShrinkCoeffectAccessDeniedException : Exception + { + public ShrinkCoeffectAccessDeniedException(string key, string message) + : base($"Coeffect access denied for '{key}': {message}") + { + Key = key; + } + + public string Key { get; } + } + /// 两个活跃纤程试图供给同一个 realm(论文定义 45 的供给不相交约束)。 public sealed class ShrinkSupplyConflictException : Exception { diff --git a/Assets/Modules/ShrinkContext.Core/Runtime/Context/ShrinkCtx.cs b/Assets/Modules/ShrinkContext.Core/Runtime/Context/ShrinkCtx.cs index 826d59b..6d83f2c 100644 --- a/Assets/Modules/ShrinkContext.Core/Runtime/Context/ShrinkCtx.cs +++ b/Assets/Modules/ShrinkContext.Core/Runtime/Context/ShrinkCtx.cs @@ -15,14 +15,17 @@ namespace ShrinkContext { private readonly List _effects = new(); private readonly Dictionary? _isolateOverlay; + private Dictionary>? _interceptOverlay; internal ShrinkCtx(ShrinkContextRuntime runtime, ShrinkCtx? parent, ShrinkFiber? ownerFiber, - Dictionary? isolateOverlay) + Dictionary? isolateOverlay, + IReadOnlyDictionary>? interceptOverlay = null) { Runtime = runtime; Parent = parent; OwnerFiber = ownerFiber; _isolateOverlay = isolateOverlay; + ReplaceIntercept(interceptOverlay); } public ShrinkContextRuntime Runtime { get; } @@ -106,7 +109,17 @@ namespace ShrinkContext /// 提供依赖(set 是可逆效应):立即安装绑定并通知依赖者; /// 逆操作撤回绑定并再次通知。句柄同时挂在当前上下文上,随上下文卸载自动撤回。 /// - public ShrinkEffectHandle Set(string key, T value) => Runtime.SetBinding(this, key, value); + public ShrinkEffectHandle Set(string key, T value) => Runtime.SetBinding(this, key, value, null); + + /// 提供带访问策略的依赖;策略只在介导 Get 时运行,原始编排查找不会运行策略。 + public ShrinkEffectHandle Set(string key, T value, IShrinkCoeffectAccessPolicy accessPolicy) => + Runtime.SetBinding(this, key, value, accessPolicy ?? throw new ArgumentNullException(nameof(accessPolicy))); + + public ShrinkEffectHandle Set(ShrinkKey key, T value) => + Set((key ?? throw new ArgumentNullException(nameof(key))).Id, value); + + public ShrinkEffectHandle Set(ShrinkKey key, T value, IShrinkCoeffectAccessPolicy accessPolicy) => + Set((key ?? throw new ArgumentNullException(nameof(key))).Id, value, accessPolicy); /// /// 代理介导的依赖访问(论文算法 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(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(ShrinkKey key) => Get( + (key ?? throw new ArgumentNullException(nameof(key))).Id); + /// 相同的介导规则;未激活返回 false 而不抛异常。 public bool TryGet(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(this, key, binding); return true; } if (fiber.InjectSet.Contains(key)) @@ -148,6 +164,9 @@ namespace ShrinkContext return false; } + public bool TryGet(ShrinkKey key, out T value) => TryGet( + (key ?? throw new ArgumentNullException(nameof(key))).Id, out value); + /// /// 派生隔离子上下文(论文定义 28/29 的派生实现): /// 覆盖 key 的 realm 解析;父上下文不受影响;丢弃子上下文即隐式恢复。 @@ -162,6 +181,33 @@ namespace ShrinkContext return new ShrinkCtx(Runtime, this, OwnerFiber, new Dictionary { [key] = realm }); } + public ShrinkCtx Isolate(ShrinkKey key, string realm) => Isolate( + (key ?? throw new ArgumentNullException(nameof(key))).Id, realm); + + /// + /// 派生带拦截元数据的子上下文。子级同名 metadata 覆盖父级;丢弃子上下文即恢复。 + /// 该操作不改变 realm、provider 或 target,因此不会触发 fiber 重载。 + /// + public ShrinkCtx Intercept(string key, IReadOnlyDictionary 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(StringComparer.Ordinal); + foreach (var pair in metadata) + metadataCopy[pair.Key] = pair.Value; + var overlay = new Dictionary>(StringComparer.Ordinal) + { + [key] = metadataCopy + }; + return new ShrinkCtx(Runtime, this, OwnerFiber, null, overlay); + } + + public ShrinkCtx Intercept(ShrinkKey key, IReadOnlyDictionary metadata) => + Intercept((key ?? throw new ArgumentNullException(nameof(key))).Id, metadata); + internal ShrinkCtx CreateChildForFiber(ShrinkFiber fiber) => new(Runtime, this, fiber, null); /// 在派生子上下文中叠加隔离覆盖(同键后者优先),供加载器为条目配置隔离域。 @@ -180,6 +226,38 @@ namespace ShrinkContext return new ShrinkCtx(Runtime, this, OwnerFiber, overlay); } + /// 在当前纤程上下文上叠加 loader 条目配置的 intercept。 + internal ShrinkCtx WithIntercept( + IReadOnlyDictionary> intercept) => + new(Runtime, this, OwnerFiber, null, interceptOverlay: intercept); + + /// 原地替换本层 intercept,供 loader 在不重载 fiber 的情况下更新访问策略。 + internal void ReplaceIntercept( + IReadOnlyDictionary>? intercept) + { + if (intercept == null || intercept.Count == 0) + { + _interceptOverlay = null; + return; + } + + var copy = new Dictionary>(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(StringComparer.Ordinal); + foreach (var metadataPair in pair.Value) + metadataCopy[metadataPair.Key] = metadataPair.Value; + copy[pair.Key] = metadataCopy; + } + + _interceptOverlay = copy; + } + /// 解析 key 在本上下文中归属的 realm:沿父链找最近的隔离覆盖,默认 realm 为 key 自身。 internal string ResolveRealm(string key) { @@ -193,5 +271,30 @@ namespace ShrinkContext return key; } + + /// 按根到叶顺序合并拦截元数据;越接近访问方的上下文优先。 + internal IReadOnlyDictionary ResolveInterceptMetadata(string key) + { + var chain = new Stack(); + for (var current = this; current != null; current = current.Parent) + chain.Push(current); + + var merged = new Dictionary(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 GetLocalInterceptKeys() => + _interceptOverlay?.Keys ?? (IReadOnlyCollection)Array.Empty(); } } diff --git a/Assets/Modules/ShrinkContext.Core/Runtime/Fibers/ShrinkFiber.cs b/Assets/Modules/ShrinkContext.Core/Runtime/Fibers/ShrinkFiber.cs index 0b99157..fbf9f66 100644 --- a/Assets/Modules/ShrinkContext.Core/Runtime/Fibers/ShrinkFiber.cs +++ b/Assets/Modules/ShrinkContext.Core/Runtime/Fibers/ShrinkFiber.cs @@ -27,6 +27,7 @@ namespace ShrinkContext Parent = parent; Config = config; InjectSet = new HashSet(component.Inject ?? Array.Empty(), StringComparer.Ordinal); + ProvideSet = new HashSet(component.Provide ?? Array.Empty(), StringComparer.Ordinal); } public long Uid { get; } @@ -62,5 +63,6 @@ namespace ShrinkContext internal bool InTransition; internal bool Retired; internal HashSet InjectSet { get; } + internal HashSet ProvideSet { get; } } } diff --git a/Assets/Modules/ShrinkContext.Core/Runtime/Loader/ShrinkLoader.cs b/Assets/Modules/ShrinkContext.Core/Runtime/Loader/ShrinkLoader.cs index 9eafa1d..e9c9f3a 100644 --- a/Assets/Modules/ShrinkContext.Core/Runtime/Loader/ShrinkLoader.cs +++ b/Assets/Modules/ShrinkContext.Core/Runtime/Loader/ShrinkLoader.cs @@ -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? isolate = null) + IReadOnlyDictionary? isolate = null, + IReadOnlyDictionary>? 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; } /// 稳定协调键:条目增删与更新的 diff 依据。 @@ -37,6 +40,9 @@ namespace ShrinkContext /// 条目级隔离域:键 → realm,叠加到该纤程自身的解析。 public IReadOnlyDictionary? Isolate { get; } + + /// 条目级访问元数据:依赖键 → metadata。更新时不改变 target,也不重载 fiber。 + public IReadOnlyDictionary>? Intercept { get; } } /// 组件目录:条目的组件名 → 组件工厂。装配期由宿主/CodeGen 注册,加载器不做全域反射扫描。 @@ -69,23 +75,68 @@ namespace ShrinkContext component = null!; return false; } + + public bool Contains(string name) => _factories.ContainsKey(name); } /// 加载器配置错误(重复条目 id、未知组件名等)。 - 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, + } + + /// 最近一次声明式协调的稳定诊断结果。 + 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; } + } + /// /// 声明式组件加载器(论文 5.2 的原型子集): /// 编排者把期望组合表达为条目列表,加载器将其增量协调为纤程操作—— /// 条目消失 → 退役;disabled → 卸载;组件/配置变化 → 重建;其余保持不动(幂等)。 /// - /// 与完整实现的差异(后续阶段补齐):config 变化当前统一走重建而非组件自决 diff; - /// isolate 变化走重建而非领域原地重分配;intercept 尚未支持。 + /// 与完整实现的差异:config 变化当前统一走重建而非组件自决 diff; + /// isolate 变化仍走重建而非领域原地重分配。intercept metadata 可原地更新,不触发重载。 /// public sealed class ShrinkContextLoader { @@ -95,12 +146,15 @@ namespace ShrinkContext public object? Config; public bool Disabled; public IReadOnlyDictionary? Isolate; + public IReadOnlyDictionary>? Intercept; public ShrinkFiber? Fiber; } private readonly ShrinkContextRuntime _runtime; private readonly ShrinkComponentCatalog _catalog; private readonly Dictionary _entries = new(StringComparer.Ordinal); + private long _transactionGeneration; + private bool _applying; public ShrinkContextLoader(ShrinkContextRuntime runtime, ShrinkComponentCatalog catalog) { @@ -111,6 +165,8 @@ namespace ShrinkContext /// 当前托管条目 id(含 disabled 但仍被管理的条目)。 public IEnumerable ManagedEntryIds => _entries.Keys; + public ShrinkLoaderTransactionDiagnostic? LastTransaction { get; private set; } + /// 查询条目当前纤程(可能处于 Inactive——依赖缺失时被响应式停用但仍被管理)。 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 desired, + ShrinkLoaderTransactionDiagnostic transaction, bool restoring) + { + transaction.Phase = restoring + ? ShrinkLoaderTransactionPhase.Restoring + : ShrinkLoaderTransactionPhase.Validating; var desiredIds = new HashSet(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(); 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 CaptureEntries() + { + var entries = new List(_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? a, IReadOnlyDictionary? b) { if (a == null && b == null) @@ -228,5 +382,31 @@ namespace ShrinkContext return true; } + + private static bool InterceptEquals( + IReadOnlyDictionary>? a, + IReadOnlyDictionary>? 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; + } } } diff --git a/Assets/Modules/ShrinkContext.Core/Runtime/ShrinkContextDefaults.cs b/Assets/Modules/ShrinkContext.Core/Runtime/ShrinkContextDefaults.cs index ec28c82..4a15611 100644 --- a/Assets/Modules/ShrinkContext.Core/Runtime/ShrinkContextDefaults.cs +++ b/Assets/Modules/ShrinkContext.Core/Runtime/ShrinkContextDefaults.cs @@ -5,7 +5,7 @@ namespace ShrinkContext { /// /// 默认运行时入口 + Domain Reload 安全重置。 - /// 原型阶段不自动启动任何组件;编排方式(配置树加载器)属于后续阶段。 + /// 不自动启动任何组件;上层 composition root 通过 ShrinkContextLoader 提交期望组合。 /// public static class ShrinkContextDefaults { diff --git a/Assets/Modules/ShrinkContext.Core/Runtime/ShrinkContextDiagnostics.cs b/Assets/Modules/ShrinkContext.Core/Runtime/ShrinkContextDiagnostics.cs new file mode 100644 index 0000000..a612858 --- /dev/null +++ b/Assets/Modules/ShrinkContext.Core/Runtime/ShrinkContextDiagnostics.cs @@ -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, + } + + /// 单个依赖键在快照时刻的解析、提交与潜在提供者。 + public sealed class ShrinkDependencyDiagnostic + { + public ShrinkDependencyDiagnostic(string key, string realm, long? currentProviderUid, + long? committedProviderUid, IReadOnlyList 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 PotentialProviderUids { get; } + public bool IsSatisfied => CurrentProviderUid.HasValue; + } + + /// 一个 fiber 的稳定只读诊断投影,不暴露可变运行时对象。 + public sealed class ShrinkFiberDiagnostic + { + public ShrinkFiberDiagnostic(long uid, string name, ShrinkFiberDiagnosticStatus status, + ShrinkFiberState lifecycleState, bool retired, bool inTransition, + IReadOnlyList inject, IReadOnlyList provide, + IReadOnlyList targetProviderUids, + IReadOnlyList dependencies, + IReadOnlyList 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 Inject { get; } + public IReadOnlyList Provide { get; } + public IReadOnlyList TargetProviderUids { get; } + public IReadOnlyList Dependencies { get; } + public IReadOnlyList 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; } + } + + /// 运行时整体快照:fiber、绑定与 notify 索引状态。 + public sealed class ShrinkContextRuntimeDiagnostic + { + public ShrinkContextRuntimeDiagnostic(IReadOnlyList fibers, + int bindingCount, ShrinkNotificationDiagnostic notifications) + { + Fibers = fibers; + BindingCount = bindingCount; + Notifications = notifications; + } + + public IReadOnlyList Fibers { get; } + public int BindingCount { get; } + public ShrinkNotificationDiagnostic Notifications { get; } + } +} diff --git a/Assets/Modules/ShrinkContext.Core/Runtime/ShrinkContextDiagnostics.cs.meta b/Assets/Modules/ShrinkContext.Core/Runtime/ShrinkContextDiagnostics.cs.meta new file mode 100644 index 0000000..303d4ab --- /dev/null +++ b/Assets/Modules/ShrinkContext.Core/Runtime/ShrinkContextDiagnostics.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1ee8044af3885c84a9b86b101af8ae28 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkContext.Core/Runtime/ShrinkContextRuntime.cs b/Assets/Modules/ShrinkContext.Core/Runtime/ShrinkContextRuntime.cs index 1fd86e1..da8ad68 100644 --- a/Assets/Modules/ShrinkContext.Core/Runtime/ShrinkContextRuntime.cs +++ b/Assets/Modules/ShrinkContext.Core/Runtime/ShrinkContextRuntime.cs @@ -13,14 +13,20 @@ namespace ShrinkContext /// 约定: /// - 仅在主线程使用; /// - 异步转换在同步可完成路径上内联跑完(无需 PlayerLoop 泵即可确定性测试); - /// - 目标(Target)以提供者 uid 集合表示,uid 永不复用,因此“同值不同提供者”的替换也会触发重载。 + /// - 目标(Target)按 inject 声明顺序记录 provider uid 视图,uid 永不复用, + /// 因此“同值不同提供者”的替换及多键 provider 映射变化都能触发重载。 /// public sealed class ShrinkContextRuntime { private readonly ShrinkCtx _rootCtx; private readonly List _fibers = new(); private readonly Dictionary _store = new(); + private readonly Dictionary> _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 为该纤程自身的键解析叠加隔离域(不影响父上下文)。 /// public ShrinkFiber Use(IShrinkComponent component, object? config = null, ShrinkCtx? parentCtx = null, - IReadOnlyDictionary? isolate = null) + IReadOnlyDictionary? isolate = null, + IReadOnlyDictionary>? 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) ---------------- /// - /// 重算目标:任一依赖键不可解析 → ⊥;否则为全部提供者 uid 的有序集合。 + /// 重算目标:任一依赖键不可解析 → ⊥;否则为按 inject 键顺序记录的 provider uid 视图。 /// 目标变化即启动(或在惯性边界链式触发)reload / unload 转换。 /// internal bool Refresh(ShrinkFiber fiber) @@ -222,19 +233,38 @@ namespace ShrinkContext internal IReadOnlyList Notify(ShrinkCtx sourceCtx, IReadOnlyCollection keys) { var affected = new List(); - foreach (var fiber in _fibers.ToArray()) + if (keys.Count == 0) + return affected; + + var candidates = new HashSet(); + 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(ShrinkCtx ctx, ShrinkKey key, out T value) => TryGetRaw( + ctx, (key ?? throw new ArgumentNullException(nameof(key))).Id, out value); + + /// 执行 provider 绑定的访问策略;无策略时直接返回原始值。 + internal T ResolveAccess(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 ?? ""}' " + + $"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 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(); + _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); + } + } + + /// 捕获不含可变运行时引用的诊断快照。 + public ShrinkContextRuntimeDiagnostic CaptureDiagnostic() + { + var diagnostics = new List(_fibers.Count); + foreach (var fiber in _fibers.OrderBy(item => item.Uid)) + { + var dependencies = new List(); + foreach (var key in fiber.Component.Inject ?? Array.Empty()) + { + 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()).ToArray(), + (fiber.Component.Provide ?? Array.Empty()).ToArray(), + fiber.Target?.ToArray() ?? Array.Empty(), + 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, + }; + } } } diff --git a/Assets/Modules/ShrinkContext.Core/Tests/CoeffectAccessTests.cs b/Assets/Modules/ShrinkContext.Core/Tests/CoeffectAccessTests.cs index 2909fb4..8520f1e 100644 --- a/Assets/Modules/ShrinkContext.Core/Tests/CoeffectAccessTests.cs +++ b/Assets/Modules/ShrinkContext.Core/Tests/CoeffectAccessTests.cs @@ -95,6 +95,40 @@ namespace ShrinkContext.Tests Assert.AreEqual(1, rootValueFinal); } + [Test] + public void TypedKey_PreservesVersionedIdentityAcrossMediatedAccess() + { + var key = new ShrinkKey("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 + { + ["prefix"] = "parent-", + ["suffix"] = "-parent" + }); + var child = parent.Intercept(key, new Dictionary + { + ["suffix"] = "-child" + }); + + Assert.AreEqual("parent-value-child", child.Get(key)); + } + /// 声明一个键却访问另一个未声明键的组件(验证能力介导纪律)。 private sealed class GreedyConsumerComponent : IShrinkComponent { diff --git a/Assets/Modules/ShrinkContext.Core/Tests/FiberLifecycleTests.cs b/Assets/Modules/ShrinkContext.Core/Tests/FiberLifecycleTests.cs index bfd045e..7201dc0 100644 --- a/Assets/Modules/ShrinkContext.Core/Tests/FiberLifecycleTests.cs +++ b/Assets/Modules/ShrinkContext.Core/Tests/FiberLifecycleTests.cs @@ -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(fiber.LastError); + Assert.IsFalse(_runtime.TryGetRaw(_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() { diff --git a/Assets/Modules/ShrinkContext.Core/Tests/LoaderTests.cs b/Assets/Modules/ShrinkContext.Core/Tests/LoaderTests.cs index cb3e2ee..8501f15 100644 --- a/Assets/Modules/ShrinkContext.Core/Tests/LoaderTests.cs +++ b/Assets/Modules/ShrinkContext.Core/Tests/LoaderTests.cs @@ -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 { ["svc"] = "r1" }; + var isolateR2 = new Dictionary { ["svc"] = "r2" }; + TestAwait.Run(_loader.ApplyAsync(new List + { + 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 + { + 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> + { + ["policy.service"] = new Dictionary { ["suffix"] = "-a" } + }; + var suffixB = new Dictionary> + { + ["policy.service"] = new Dictionary { ["suffix"] = "-b" } + }; + + TestAwait.Run(_loader.ApplyAsync(new List + { + 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 + { + 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 + { + new("provider-entry", "provider", "stable") + })); + + Assert.Throws(() => TestAwait.Run(_loader.ApplyAsync( + new List { 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); + } } } diff --git a/Assets/Modules/ShrinkContext.Core/Tests/TestComponents.cs b/Assets/Modules/ShrinkContext.Core/Tests/TestComponents.cs index 1805cd2..98d61f9 100644 --- a/Assets/Modules/ShrinkContext.Core/Tests/TestComponents.cs +++ b/Assets/Modules/ShrinkContext.Core/Tests/TestComponents.cs @@ -250,6 +250,81 @@ namespace ShrinkContext.Tests } } + /// 故意写入未声明键,验证 Provide 运行时契约。 + public sealed class UndeclaredProviderComponent : IShrinkComponent + { + public string Name => "undeclared-provider"; + public IReadOnlyList Inject => Array.Empty(); + public IReadOnlyList Provide => Array.Empty(); + + 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("prefix", out var prefix)) + text = prefix + text; + return context.TryGetMetadata("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 Inject => Array.Empty(); + public IReadOnlyList 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 Inject => _inject; + public IReadOnlyList Provide => Array.Empty(); + + 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(Key); + } + /// 测试辅助:同步等待 UniTask(测试保证全部延续同步完成,无死锁风险)。 public static class TestAwait { diff --git a/Assets/Modules/ShrinkDataSaver.Integration.App/CHANGELOG.md b/Assets/Modules/ShrinkDataSaver.Integration.App/CHANGELOG.md new file mode 100644 index 0000000..453ee5f --- /dev/null +++ b/Assets/Modules/ShrinkDataSaver.Integration.App/CHANGELOG.md @@ -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` 作为宿主服务门面。 diff --git a/Assets/Modules/ShrinkDataSaver.Integration.App/CHANGELOG.md.meta b/Assets/Modules/ShrinkDataSaver.Integration.App/CHANGELOG.md.meta new file mode 100644 index 0000000..73974a0 --- /dev/null +++ b/Assets/Modules/ShrinkDataSaver.Integration.App/CHANGELOG.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3c6cf6fb5b92c5848b40e963a80a58c1 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkDataSaver.Integration.App/README.md b/Assets/Modules/ShrinkDataSaver.Integration.App/README.md new file mode 100644 index 0000000..1e11b9e --- /dev/null +++ b/Assets/Modules/ShrinkDataSaver.Integration.App/README.md @@ -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 + { + [ShrinkDataSaverAccess.MetadataKey] = ShrinkDataSaverAccess.ReadOnly + }); + +var reader = communityContext.Get( + ShrinkDataSaverAppComponent.ServiceKey); +``` + +这是服务能力的最小化暴露:只约束通过 `ShrinkCtx.Get` 解析该键的调用方。它不会限制代码绕过上下文后直接访问静态 API,也不构成不可信模组沙箱。 diff --git a/Assets/Modules/ShrinkDataSaver.Integration.App/README.md.meta b/Assets/Modules/ShrinkDataSaver.Integration.App/README.md.meta new file mode 100644 index 0000000..0b4d654 --- /dev/null +++ b/Assets/Modules/ShrinkDataSaver.Integration.App/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 85bcc5ac108273c41acdd89c5e2d0291 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkDataSaver.Integration.App/Runtime.meta b/Assets/Modules/ShrinkDataSaver.Integration.App/Runtime.meta new file mode 100644 index 0000000..13ca782 --- /dev/null +++ b/Assets/Modules/ShrinkDataSaver.Integration.App/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 79b02908f32342443bd1a986d535a7d2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkDataSaver.Integration.App/Runtime/ShrinkDataSaver.Integration.App.asmdef b/Assets/Modules/ShrinkDataSaver.Integration.App/Runtime/ShrinkDataSaver.Integration.App.asmdef new file mode 100644 index 0000000..1789bab --- /dev/null +++ b/Assets/Modules/ShrinkDataSaver.Integration.App/Runtime/ShrinkDataSaver.Integration.App.asmdef @@ -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 +} diff --git a/Assets/Modules/ShrinkDataSaver.Integration.App/Runtime/ShrinkDataSaver.Integration.App.asmdef.meta b/Assets/Modules/ShrinkDataSaver.Integration.App/Runtime/ShrinkDataSaver.Integration.App.asmdef.meta new file mode 100644 index 0000000..b6e8a15 --- /dev/null +++ b/Assets/Modules/ShrinkDataSaver.Integration.App/Runtime/ShrinkDataSaver.Integration.App.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b6cfc65e2b6dc8942866f0f374874564 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkDataSaver.Integration.App/Runtime/ShrinkDataSaverAppComponent.cs b/Assets/Modules/ShrinkDataSaver.Integration.App/Runtime/ShrinkDataSaverAppComponent.cs new file mode 100644 index 0000000..eb4b937 --- /dev/null +++ b/Assets/Modules/ShrinkDataSaver.Integration.App/Runtime/ShrinkDataSaverAppComponent.cs @@ -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 +{ + /// + /// ShrinkDataSaver 的原生 Cordis 组件(阶段 3): + /// - 提供 shrink.service.datasaver 余效应键(门面服务); + /// - 初始化为前向;停用时的终止动作为最终落盘设置(补偿式收尾: + /// 存档写入本身跨会话持久,不可严格求逆,以 flush 保证一致性,见论文 6.1 扣留与补偿); + /// - 与经典 ShrinkDataSaverAppInstaller(ModuleId "shrink.datasaver")二选一使用。 + /// + 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 Inject => Array.Empty(); + + public IReadOnlyList 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; + }; + }); + } + } +} diff --git a/Assets/Modules/ShrinkDataSaver.Integration.App/Runtime/ShrinkDataSaverAppComponent.cs.meta b/Assets/Modules/ShrinkDataSaver.Integration.App/Runtime/ShrinkDataSaverAppComponent.cs.meta new file mode 100644 index 0000000..d3849d9 --- /dev/null +++ b/Assets/Modules/ShrinkDataSaver.Integration.App/Runtime/ShrinkDataSaverAppComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0ce2260a6cfecb349abe6119fdf37c12 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkDataSaver.Integration.App/Runtime/ShrinkDataSaverAppInstaller.cs b/Assets/Modules/ShrinkDataSaver.Integration.App/Runtime/ShrinkDataSaverAppInstaller.cs new file mode 100644 index 0000000..a1bdf6b --- /dev/null +++ b/Assets/Modules/ShrinkDataSaver.Integration.App/Runtime/ShrinkDataSaverAppInstaller.cs @@ -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 DependsOn => Array.Empty(); + + public void RegisterServices(ShrinkAppContext context) + { + context.Services.Register(new ShrinkDataSaverService()); + } + + public UniTask InitializeAsync(ShrinkAppContext context) + { + ShrinkDataSaverRuntime.Initialize(); + return UniTask.CompletedTask; + } + } + + /// 不产生存档写入的读取能力。 + public interface IShrinkDataSaverReader + { + int LoadedSlot { get; } + int GetRecentSlotIndex(); + UniTask GetRecommendedContinueSlotAsync(); + UniTask GetAllMetaAsync(); + } + + /// 完整存档能力;社区模组可通过 intercept 被降权为仅 + 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 GetRecommendedContinueSlotAsync() => ShrinkSave.GetRecommendedContinueSlotAsync(); + public UniTask 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 GetRecommendedContinueSlotAsync() => _inner.GetRecommendedContinueSlotAsync(); + public UniTask GetAllMetaAsync() => _inner.GetAllMetaAsync(); + } + + /// DataSaver provider 侧的访问介导;仅约束经 ShrinkCtx.Get 取得的能力。 + 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(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}'"); + } + } +} diff --git a/Assets/Modules/ShrinkDataSaver.Integration.App/Runtime/ShrinkDataSaverAppInstaller.cs.meta b/Assets/Modules/ShrinkDataSaver.Integration.App/Runtime/ShrinkDataSaverAppInstaller.cs.meta new file mode 100644 index 0000000..3cf8795 --- /dev/null +++ b/Assets/Modules/ShrinkDataSaver.Integration.App/Runtime/ShrinkDataSaverAppInstaller.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9eea84dd572634c438c99aef69c51d9f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkDataSaver.Integration.App/package.json b/Assets/Modules/ShrinkDataSaver.Integration.App/package.json new file mode 100644 index 0000000..8d86a37 --- /dev/null +++ b/Assets/Modules/ShrinkDataSaver.Integration.App/package.json @@ -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" + } +} \ No newline at end of file diff --git a/Assets/Modules/ShrinkDataSaver.Integration.App/package.json.meta b/Assets/Modules/ShrinkDataSaver.Integration.App/package.json.meta new file mode 100644 index 0000000..a188326 --- /dev/null +++ b/Assets/Modules/ShrinkDataSaver.Integration.App/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 8358963c0e3299e478b163272aa6b09b +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkModFramework/README.md b/Assets/Modules/ShrinkModFramework/README.md index f499122..a778a9b 100644 --- a/Assets/Modules/ShrinkModFramework/README.md +++ b/Assets/Modules/ShrinkModFramework/README.md @@ -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 与其效应 ## 关键文件 diff --git a/Assets/Modules/ShrinkModFramework/Runtime/Bootstrap/ShrinkModFrameworkSettings.cs b/Assets/Modules/ShrinkModFramework/Runtime/Bootstrap/ShrinkModFrameworkSettings.cs index 9b8c7bb..4f7a5c9 100644 --- a/Assets/Modules/ShrinkModFramework/Runtime/Bootstrap/ShrinkModFrameworkSettings.cs +++ b/Assets/Modules/ShrinkModFramework/Runtime/Bootstrap/ShrinkModFrameworkSettings.cs @@ -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; diff --git a/Assets/Modules/ShrinkModFramework/Runtime/Loading/ShrinkExternalModAssemblyLoader.cs b/Assets/Modules/ShrinkModFramework/Runtime/Loading/ShrinkExternalModAssemblyLoader.cs index 059fca7..efc1597 100644 --- a/Assets/Modules/ShrinkModFramework/Runtime/Loading/ShrinkExternalModAssemblyLoader.cs +++ b/Assets/Modules/ShrinkModFramework/Runtime/Loading/ShrinkExternalModAssemblyLoader.cs @@ -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 CurrentAssemblyRevisions = new(StringComparer.OrdinalIgnoreCase); - private static readonly HashSet ExternalAssemblyHistory = new(); + private static readonly Dictionary ExternalAssemblyHistory = new(); private static readonly Dictionary KnownAssemblyFiles = new(StringComparer.OrdinalIgnoreCase); private static readonly object ResolveLock = new(); private static bool _resolveRegistered; + private static int _lastWarnedResidentCount; public static IReadOnlyList 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( + 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) diff --git a/Assets/Modules/ShrinkModFramework/Runtime/Loading/ShrinkModDiagnostics.cs b/Assets/Modules/ShrinkModFramework/Runtime/Loading/ShrinkModDiagnostics.cs new file mode 100644 index 0000000..68c15c5 --- /dev/null +++ b/Assets/Modules/ShrinkModFramework/Runtime/Loading/ShrinkModDiagnostics.cs @@ -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; } + } + + /// Mono 外部程序集的 current 与常驻历史快照;LoadedBytes 仅统计载入 DLL/PDB 文件大小。 + public sealed class ShrinkExternalAssemblyDiagnostic + { + internal ShrinkExternalAssemblyDiagnostic(int currentRevisionCount, + IReadOnlyList residentRevisions, + long estimatedResidentBytes, int softLimit) + { + CurrentRevisionCount = currentRevisionCount; + ResidentRevisions = residentRevisions; + EstimatedResidentBytes = estimatedResidentBytes; + SoftLimit = softLimit; + } + + public int CurrentRevisionCount { get; } + public IReadOnlyList 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); + } + } +} diff --git a/Assets/Modules/ShrinkModFramework/Runtime/Loading/ShrinkModDiagnostics.cs.meta b/Assets/Modules/ShrinkModFramework/Runtime/Loading/ShrinkModDiagnostics.cs.meta new file mode 100644 index 0000000..24c404b --- /dev/null +++ b/Assets/Modules/ShrinkModFramework/Runtime/Loading/ShrinkModDiagnostics.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 20342cb92d99bc34ca99a41312b5a95d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkModFramework/Tests/ShrinkModContextHostTests.cs b/Assets/Modules/ShrinkModFramework/Tests/ShrinkModContextHostTests.cs index 4d71930..8209e85 100644 --- a/Assets/Modules/ShrinkModFramework/Tests/ShrinkModContextHostTests.cs +++ b/Assets/Modules/ShrinkModFramework/Tests/ShrinkModContextHostTests.cs @@ -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 { diff --git a/CORDIS_MIGRATION.md b/CORDIS_MIGRATION.md index a16d64a..dc6fd9c 100644 --- a/CORDIS_MIGRATION.md +++ b/CORDIS_MIGRATION.md @@ -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` 版本键、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 的配置、调试面与容量基准,不再新增第三套生命周期。