diff --git a/.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md b/.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md index 1ba98a1d4e..e3ffb2c1cf 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md +++ b/.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md @@ -71,6 +71,22 @@ JDK 也一样:`ProjectRuntimeService` 不再问“app 内置的 JDK 在哪” 同一工作区的服务连续三次启动失败后,自动同步直接返回已有失败,直到显式停止/重启或切换工作区;进入 ready 后清零。这保留短暂故障后的重试,但阻止每次编辑器同步重新创建 JVM。这里选择有限尝试而非增加定时器和无限退避;代价是持续失败需要用户修复环境后重新启动服务。 +#### 原生日志属于宿主日志会话 + +Java Support 使用 Equinox 的 `osgi.logfile` 把原生错误日志写入 `${logDir}/jdt.log`, +宿主在启动前提供当前会话的可写目录,插件安装包和工作区索引不承担日志所有权。 +`eclipse.log.include.commandline=false` 避免运行参数进入原生文件,单段 1024 KiB、两个备份, +把该日志限制在约 3 MiB;`eclipse.log.level=WARNING` 控制 Equinox 原生日志,JDT 的 `logLevel` 默认同为 WARNING。 +用户仍可明确调整既有日志级别,但原生文件不等同于 Core 脱敏后的结构化事件。 + +依据是 Equinox `EclipseStarter.PROP_LOGFILE` 与 `EquinoxLogWriter` 的官方属性; +不要把 Eclipse workspace 的 `.metadata` 缓存整个搬入日志目录,或复制源码和协议正文作为诊断。 +清单依赖已有 `${logDir}` 契约:当前支持的是能提供本机 `logDirectory` 的宿主;远程或缺少目录的宿主按契约拒绝, +不偷偷回退到旧的工作区 `.metadata/.log`。直接 JVM 适配保留这些自定义 `-D` 参数且必须保持幂等。 +本轮没有变更既有 `log.protocol` 属性,也没有证据据此宣称协议正文已被关闭; +详细 trace 仍遵循共享日志只接纳 message 摘要、不保存 verbose 载荷的既有边界。 +组件参数测试不能替代真实 JDT 启动、日志轮转和完整工作流验收。 + ### 4. 运行、测试、调试由插件声明后交还宿主 插件一旦声明某个语言,宿主就认为这个语言的进程类能力归插件所有:能力目录(`PluginLanguageProviderCatalogSource`)去掉运行、测试、调试标记,语言包注册表(`LanguagePackRegistry`)不再创建内置的测试提供方,运行和测试服务要求必须有插件的执行模块。这条规则是为 PHP、Go 这类整套由插件提供的语言定的,目的是插件缺失或停用时宿主不偷偷退回内置工具。 diff --git a/.agents/notes/implemented/architecture/2026-11-03-structured-logging-channels.md b/.agents/notes/implemented/architecture/2026-11-03-structured-logging-channels.md index 7976a168a8..de151cf649 100644 --- a/.agents/notes/implemented/architecture/2026-11-03-structured-logging-channels.md +++ b/.agents/notes/implemented/architecture/2026-11-03-structured-logging-channels.md @@ -91,7 +91,15 @@ macOS 的宿主调用点、类型化写入与诊断收集已落地。PR4 的日 引擎在日志源头选择封闭的事实类型,既有英文 message/detail/error 留作 v1 兼容;v2 使用目录 ID 和标量字段,经过同一个 Core 目录规范化、脱敏与通道推导边界。stderr 的读取状态最多保留 4 KiB 半行,按 LF 切分、去末尾 CR、忽略空行并冲刷 EOF 残余;每单调秒接纳 200 行,后续读取跨窗或结束时汇总丢弃计数。测试直接传毫秒值,不装计时器、不等真实一秒。text 落盘前仍执行 1 KiB 规范化。 -`window/logMessage` 与 `window/showMessage` 复用结果词为过去时的 `*.message.logged/shown`;其他通知只记录方法,不把协议正文变成 text。trace 默认关闭,显式开关与发往上游的 `$/setTrace` 共用有序写锁,成功写入后才改变采集状态;只接纳限长并脱敏的 message 摘要,排除可能含 RPC 原始载荷和源码的 verbose。凭据脱敏和截断无法把源码转换为可落盘诊断;旧内存 detail 的兼容不代表允许把它写入 v2。macOS 已删除消息字符串匹配和重复 session 状态事实,按目录元数据取 v2 级别。带 Core 记录的 log 不再触发重复 onLogIssue,宿主自身启动/发现/请求失败仍走既有错误边界。 +`window/logMessage` 与 `window/showMessage` 的明确严重度选择独立目录事件:上游 error 为 ERROR、warning 为 WARN、info 为 INFO、详细或未知类型为 DEBUG。未分类 stderr 仅作为 DEBUG 排障证据,不能因为输出在标准错误流就标成警告;最终进程或协议失败在实际失败边界记录 ERROR。其他通知只记录方法,不把协议正文变成 text。trace 默认关闭,显式开关与发往上游的 `$/setTrace` 共用有序写锁,成功写入后才改变采集状态;只接纳限长并脱敏的 message 摘要,排除可能含 RPC 原始载荷和源码的 verbose。凭据脱敏和截断无法把源码转换为可落盘诊断;旧内存 detail 的兼容不代表允许把它写入 v2。macOS 已删除消息字符串匹配和重复 session 状态事实,按目录元数据取 v2 级别。带 Core 记录的 log 不再触发重复 onLogIssue,宿主自身启动/发现/请求失败仍走既有错误边界。 + +### 官方插件通过新的类型化协议交付事实 + +官方 Node、Java、Rust SDK 使用独立协商的 `litheLogging` 1,通过 `lithe/log` 把操作结果交给 Core。插件只提供注册过的操作、稳定类别、计数和单调耗时;Core 从已安装插件身份选通道和目录事件。成功终态为 INFO、实际失败为 ERROR、回退和真实恢复为 WARN。高频成功可在注册或当前调用上抑制,失败继续可见;取消不是失败。 + +采用新协议是因为 stderr 无法可靠表达处理后的结果、原操作身份与字段边界。给自由消息加 `[ERROR]` 不能解决这个问题,还会把错误正文和用户内容带入日志。正确做法是 SDK 在结果边界发送一条类型化终态,处理函数只补它真正拥有的降级;不要先记录错误再返回同一次错误,或把 SQL、HTTP 正文、构建输出写进诊断。 + +Core 只接纳协商成功且字段符合封闭契约的事实,每个进程会话最多每分钟 600 条,使用现有 256 条队列与单一宿主写入器。非法输入每会话只告警一次,不回显被拒内容;越限只计丢弃数。SDK 初始化阶段最多缓存八条类型化事实,capability 响应后排空;正常终态在 RPC 响应前发出,使 Core 能将 wire 请求编号映射到真实宿主 op。协议与 fixture 以共享插件契约为准,不再维护自由文本日志 API。 详细诊断已开启时,新会话注册可能早于初始化响应。Core 此时只保存最新开关值,等握手完成后把 `$/setTrace` 排在 `initialized` 后;初始化前关闭会撤销待发送的启用。不要在注册时立即写通知,否则严格遵循 LSP 的服务器会丢弃它,宿主却以为已经开启。 @@ -103,6 +111,8 @@ Core 拥有会话生命周期状态。`*.session.updated` 直接附到已有 `ty 六处非 LSP 直接打印改为进程级 Core 队列:只接纳 owner 为 lithe-core 的目录事实,入队前规范化,最多 256 条,重复待取事实与超额计数,由宿主一次 drain 取走并重置。队列没有文件 writer、后台任务或独立持久化;遍历只记相对路径和稳定错误类别,远程清理只记类别,不记录地址、scratch 名称或输出。现有请求事件流只覆盖单个请求,无法覆盖析构和后台清理,因此选择独立有界 handoff(交给宿主写入的内存队列)。 +只靠宿主 drain 取走时,没有其他宿主活动就没有人来取:插件 `lithe/log` 事实、析构和后台清理事实会一直留在内存,既不落盘,也不推给日志窗口。因此 Core 在队列旁保留一个唤醒回调槽位。drain 之后第一条事实或第一次丢弃计数触发一次回调,同一 drain 周期内的后续变化合并,不再回调;drain 在队列锁内清除请求标记,之后入队的事实一定会再次触发。安装时已有待取事实也立即回调。回调在持有槽位锁时同步执行,移除也取这把锁,所以移除返回后 Core 不会再使用宿主的 context,宿主可以释放它。回调不得重入 Core,只负责安排宿主已有的 drain。这个槽位属于已有的进程队列,不是新的日志单例:它不写文件、不持有 sink,服务的日志依赖仍从构造器注入。 + `${logDir}` 从启动上下文的绝对 `logDirectory` 展开,共用语言服务与后端启动路径;宿主创建当前应用会话的插件 files 目录。Core 只拼接文本,不创建目录,不从插件安装位置推导可写位置。这些文件属于运行状态,不跨 worktree 复用,不改变签名或 Sparkle delta 源基线。macOS 当前日志会话目录由统一存储器提供,Core plugin_files_path 决定相对部分。平台以 mkdirat/openat/O_NOFOLLOW 逐段创建 0700 目录并拒绝符号链接,避免检查与创建之间被换成越界链接。存储不可用时省略路径;使用变量的模板由 Core 按既有 invalidRequest 拒绝。远程进程无法写本机日志目录,同样省略并拒绝该变量。安装阶段只用未创建的占位目录校验展开。静态只读边界验证与组件测试不等于真实应用 bundle hash smoke。 目录适配器抛出的真实 I/O 错误也按存储不可用处理,由 Core 检查模板是否依赖 `${logDir}`。不使用该变量的插件继续启动;不能把可选日志目录变成全部语言服务、构建、VCS、后端和调试器的前置条件。 @@ -121,7 +131,7 @@ v1 的 detail/message/level 是兼容边界,必须保持原字节:例如 pro Core 的目录遍历和 scratch 清理复用 PR1 已声明的 app.search.directory_skipped、app.workspace.directory_skipped 和 app.workspace.scratch_cleanup_failed;error 只含固定消息、稳定类别与固定 domain,不含绝对路径、scratch 名或远程地址。删除重复 app.project.directory_skipped、remote.scratch.cleanup_failed。remote process/upload 按 ClientError 变体映射 timeout、closed、transport、spawn、protocol 或 remote_<协议码>,不保留正文。 -CoreLogRecord 只接受标量,保留 event、目录 level、channel、op 和源 ts。LogSink 的 MacLogStore、LogScope 和测试 TypedLogRecorder 三个实现均支持它。MacLogStore 在原 drain/flush/finishSession 节奏批量调用一次 drainCoreEvents,由同一个 writer 分配 seq 并 validateCatalog:true;丢弃汇总转为 app 的 log.dropped,不增加空闲轮询或独立 writer。 +CoreLogRecord 只接受标量,保留 event、目录 level、channel、op 和源 ts。LogSink 的 MacLogStore、LogScope 和测试 TypedLogRecorder 三个实现均支持它。MacLogStore 每次 drain、flush 和 finishSession 都批量调用一次 drainCoreEvents,由同一个 writer 分配 seq 并 validateCatalog:true;丢弃汇总转为 app 的 log.dropped。每个进程只有组合根创建的应用 writer 安装 Core 唤醒回调;回调只复用宿主事件已有的 100 ms 单次 drain 调度,已有调度时不重复安排,不增加空闲轮询或独立 writer。测试和其他临时 MacLogStore 不安装,避免两个 writer 争抢同一个进程队列。finishSession 先停止接纳并移除回调,再做最后一次 drain。 详细诊断不在宿主另写分类:会话通道根按 Core `diagnostics.resolveLogChannel` 返回的 category 归入当前开关(lsp→language、vcs→git、plugins→plugins),与持久化 DEBUG 的准入共用 `diagnosticRoots`。当前类别开关、新会话注册和 15 分钟单次到期向活动会话发送 setDetailedDiagnostics,退出时关闭。相同失败状态只记录一次 app.logging.diagnostics_failed,避免 drain→失败日志→drain 循环;改变目标状态允许重试。 @@ -137,6 +147,19 @@ agent-host、git-host、ide-host 和 lithe-remote 客户端被链接进 Core, 日志门禁随之扩展到这四个 crate。运行在远端主机上的 `lithe-remote-server`(`rust/lithe-remote/src/server/`、`src/bin/`)和 ide-host 的独立可执行文件不链接进本机 Core,没有可注入的 sink,暂时排除在门禁外;远端服务端日志回传和 Monaco 控制台转发在 Issue #1680 中跟踪。 +### DAP:记录状态与失败,保留程序内容边界 + +调试适配器协议(DAP,调试器与宿主交换控制消息的协议)的共享状态由 Core 持有, +因此会话开始、初始化完成、结束和操作失败都由 `debug/engine.rs` 在真实转换点记录。 +通道使用校验后的适配器身份;不适合路径的身份使用稳定 SHA-256 摘要,不能让项目路径或显示名称进入通道。 +生命周期关键里程碑为 INFO,暂停、运行等内部状态为 DEBUG,超时与适配器拒绝为 ERROR。 +取消和过期引用仅为 DEBUG,不把正常取消写成产品故障;已有失败操作记录时,随后会话 Failed 不重复记录。 + +原生宿主只补自己拥有的启动失败、初始化超时和异常进程退出,以固定阶段与退出码传给生成事件。 +正确做法是 Core 根据 `OperationFailed.code` 记录稳定分类;不要把 adapter stderr、DAP 响应 message、 +调试变量、内存、源码或被调试程序输出塞入 text/error。原始 stderr 继续属于调试控制台,不代表持久化。 +共享 Core 队列复用宿主既有 drain 和队列唤醒回调,不创建第二个 writer、轮询或应用退出后仍持有的缓存。 + ## 考虑过的备选方案 - JSON Lines:通用解析方便,但每行重复键名和身份信息、手工阅读噪声较多。采用有严格语法与 fixture 的单行文本,代价是维护专门编解码器。 @@ -147,6 +170,8 @@ agent-host、git-host、ide-host 和 lithe-remote 客户端被链接进 Core, - 现在删除 v1:会破坏仍直接使用旧 Rust API 的 Windows。保留兼容入口,迁移完成后再单独评估删除。 - 辅助 crate 依赖 Core 直接记录:形成循环依赖。把事件队列拆成第三个公共 crate 可以解开循环,但目录校验和脱敏都在 Core,拆出去要么复制规则,要么只搬一个空壳队列,不如注入一个函数指针简单。 - 辅助 crate 未注入时回退到 stderr:askpass 等入口的输出是协议的一部分,回退会污染协议,也让门禁不得不为每个 crate 开 allowlist。 +- 宿主定时轮询 drainCoreEvents:空闲时也持续唤醒,落盘延迟取决于周期,违背不增加空闲轮询的约束。改为 Core 有事实时合并唤醒。 +- 每条事实回调一次,或在回调里直接 drain:突发事实会产生大量跨语言调用;在记录线程里 drain 会把磁盘 I/O 带到 Core 调用方线程,还要重入 Core。回调只安排宿主已有的单次 drain。 ## 后果 @@ -162,6 +187,7 @@ agent-host、git-host、ide-host 和 lithe-remote 客户端被链接进 Core, - `node scripts/logging/verify-log-usage.mjs` - `node --test --test-timeout=60000 scripts/logging/test-log-events.mjs` - `cargo test --manifest-path rust/Cargo.toml -p lithe-core diagnostics` +- `cargo test --manifest-path rust/Cargo.toml -p lithe-core --test core_log_waker` - `./scripts/rust-core/verify-rust-core-comments.sh` - `./scripts/architecture/verify-shared-contracts.sh` - `./scripts/windows/verify/verify-windows-boundaries.sh` @@ -173,6 +199,7 @@ fixture 覆盖往返、转义、非法输入、路径段、轮转与保留、共 ## 适用范围 - `rust/lithe-core/src/diagnostics/` +- `rust/lithe-core/src/debug/engine.rs` - `rust/lithe-agent-host/src/failure_log.rs` - `rust/lithe-git-host/src/failure_log.rs` - `rust/lithe-ide-host/src/failure_log.rs` diff --git a/.agents/notes/implemented/feature/2026-10-14-declarative-plugin-settings.md b/.agents/notes/implemented/feature/2026-10-14-declarative-plugin-settings.md index f8bfc8f132..1c733e60d7 100644 --- a/.agents/notes/implemented/feature/2026-10-14-declarative-plugin-settings.md +++ b/.agents/notes/implemented/feature/2026-10-14-declarative-plugin-settings.md @@ -39,7 +39,7 @@ JDT LS 这类语言服务器有很多启动参数,最常见的是堆内存上 ### 4. JDT 适配层让出两个参数 -直接启动 JDT 时,宿主以前会丢弃插件传来的 `-Xmx` 和 `-Dlog.level=`,再塞自己的默认值。现在这两个参数改为“插件的最后一个合法值优先,没有就用宿主默认值(`-Xmx2048m`、`-Dlog.level=ALL`,即以前的行为)”。合法性很窄:堆大小必须是数字加可选的 `k`/`m`/`g`,并且必须大于 1536 MiB(JDT LS 在不超过 1.5 GB 时会改走“分批导入”,模块坐标重复会让整个 Java 服务失败,见 `2026-10-09-jdtls-heap-ceiling-and-duplicate-maven-coordinates.md`);日志级别必须是字母数字;不合法的值被丢弃而不是传给 JVM。Java Support 的清单因此把 `heapMaxMB` 的下限和默认值都设为 2048。`-Xms`、`--add-*`、Eclipse 产品属性、元数据位置和 Lombok 仍归宿主所有。宿主另外追加一组 GC 与日志默认值,插件参数里同键的值优先(见 `2026-10-09-jdtls-heap-ceiling-and-duplicate-maven-coordinates.md` 决策第 5 条)。 +直接启动 JDT 时,宿主以前会丢弃插件传来的 `-Xmx` 和 `-Dlog.level=`,再塞自己的默认值。现在这两个参数改为“插件的最后一个合法值优先,没有就用宿主默认值(`-Xmx2048m`、`-Dlog.level=WARNING`;日志默认收紧到警告和错误)”。合法性很窄:堆大小必须是数字加可选的 `k`/`m`/`g`,并且必须大于 1536 MiB(JDT LS 在不超过 1.5 GB 时会改走“分批导入”,模块坐标重复会让整个 Java 服务失败,见 `2026-10-09-jdtls-heap-ceiling-and-duplicate-maven-coordinates.md`);日志级别必须是字母数字;不合法的值被丢弃而不是传给 JVM。Java Support 的清单因此把 `heapMaxMB` 的下限和默认值都设为 2048。`-Xms`、`--add-*`、Eclipse 产品属性、元数据位置和 Lombok 仍归宿主所有。宿主另外追加一组 GC 与日志默认值,插件参数里同键的值优先(见 `2026-10-09-jdtls-heap-ceiling-and-duplicate-maven-coordinates.md` 决策第 5 条)。 这个读取是幂等的:宿主把自己产出的参数再喂回适配层,得到相同的结果,已有的稳定性测试继续成立。 diff --git a/Plugins/mac/Official/JavaSupport/plugin.json b/Plugins/mac/Official/JavaSupport/plugin.json index f2740960dd..0b605df64a 100644 --- a/Plugins/mac/Official/JavaSupport/plugin.json +++ b/Plugins/mac/Official/JavaSupport/plugin.json @@ -39,7 +39,7 @@ "type": "enum", "title": "Language server log level", "description": "How much the Java language server writes to its own log. Quieter levels reduce disk writes.", - "default": "ALL", + "default": "WARNING", "options": [ { "value": "ALL", "title": "All messages" }, { "value": "INFO", "title": "Information" }, @@ -60,7 +60,12 @@ "packagedRuntime": "runtime/${arch}/bin/java", "runtimeArguments": [ "-Xmx${setting:heapMaxMB}m", - "-Dlog.level=${setting:logLevel}" + "-Dlog.level=${setting:logLevel}", + "-Dosgi.logfile=${logDir}/jdt.log", + "-Declipse.log.include.commandline=false", + "-Declipse.log.size.max=1024", + "-Declipse.log.backup.max=2", + "-Declipse.log.level=WARNING" ], "entry": "language-server/plugins/org.eclipse.equinox.launcher_1.8.0.v20260804-1928.jar" }, diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge+Logging.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge+Logging.swift index d054271474..09d00d2fa8 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge+Logging.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge+Logging.swift @@ -1,5 +1,6 @@ import Foundation import LitheModuleAPI +import LitheRustCore extension RustCoreBridge { typealias LogScalar = CoreLogScalar @@ -165,6 +166,21 @@ extension RustCoreBridge { try executeResult(command: "diagnostics.drainCoreEvents", payload: [String: String]()).get() } + /// Core calls `callback` at most once per drain cycle, from any thread, while + /// it owns the installation. The callback must only schedule host work. + typealias CoreLogWaker = @convention(c) (UnsafeMutableRawPointer?) -> Void + /// Replaces the process writer's pending-fact callback. Facts already + /// waiting signal synchronously, so callers must not hold a lock the + /// callback takes. `context` must stay valid until removal returns. + static func installCoreLogWaker(_ callback: CoreLogWaker, context: UnsafeMutableRawPointer) { + lithe_bridge_install_log_waker(callback, context) + } + /// After return Core never invokes the callback with `context` again. + @discardableResult + static func removeCoreLogWaker(context: UnsafeMutableRawPointer) -> Bool { + lithe_bridge_remove_log_waker(context) == 1 + } + func encodeLogs(_ request: LogBatch) throws -> LogBatchResult { try executeResult(command: "diagnostics.encodeLogBatch", payload: request).get() } diff --git a/macos/Sources/Lithe/Platform/MacOS/Logging/MacLogStore.swift b/macos/Sources/Lithe/Platform/MacOS/Logging/MacLogStore.swift index 9550747346..f4949a386c 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Logging/MacLogStore.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Logging/MacLogStore.swift @@ -34,6 +34,10 @@ final class MacLogStore: ApplicationLogging, LogWindowLogging, LogSink, @uncheck private var diagnosticCategory: String? private var diagnosticRoots: Set? private var stopped = false + // Serializes Core waker install/removal; never held while taking `lock` + // from the callback path, and never acquired by the callback itself. + private let wakeLock = NSLock() + private var coreWake: Unmanaged? // Everything below is owned by the serial I/O queue. private var diagnosticExpiry: DispatchWorkItem? private var logSessions: [String: String] = [:] @@ -89,6 +93,7 @@ final class MacLogStore: ApplicationLogging, LogWindowLogging, LogSink, @uncheck } deinit { + stopObservingCoreFacts() closeHandles() for value in subscribers.values { value.finish() } } @@ -118,12 +123,48 @@ final class MacLogStore: ApplicationLogging, LogWindowLogging, LogSink, @uncheck return 0 } if action == 2 { queue.async { [weak self] in self?.drainSafely() } } - if action == 1 { - // One-shot only while work exists; no idle polling timer. - queue.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in self?.drainSafely() } + if action == 1 { scheduleOneShotDrain() } + } + + /// One-shot only while work exists; no idle polling timer. + private func scheduleOneShotDrain() { + queue.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in self?.drainSafely() } + } + + /// Opts the process application writer into Core's pending-fact signal, so + /// facts recorded with no host activity (plugin `lithe/log`, Core recovery) + /// still reach disk and live subscribers. Other stores keep the activity + /// cadence and never take this writer's facts. Must not hold `lock`: + /// installation signals synchronously when facts are already waiting. + func observeCoreFacts() { + wakeLock.withLock { + guard coreWake == nil, !lock.withLock({ stopped }) else { return } + let target = Unmanaged.passRetained(CoreLogWakeTarget(self)) + coreWake = target + RustCoreBridge.installCoreLogWaker(coreLogWake, context: target.toOpaque()) + } + } + + /// Detaches before teardown; once removal returns no callback can use the target. + private func stopObservingCoreFacts() { + wakeLock.withLock { + guard let target = coreWake else { return } + coreWake = nil + RustCoreBridge.removeCoreLogWaker(context: target.toOpaque()) + target.release() } } + /// Core-thread entry: reuses the enqueue one-shot, so a burst of facts and + /// host events shares one drain. The drain itself never runs on Core's thread. + fileprivate func scheduleCoreDrain() { + let schedule = lock.withLock { () -> Bool in + guard !stopped, !scheduled else { return false } + scheduled = true; return true + } + if schedule { scheduleOneShotDrain() } + } + /// Called only with the channel Core returned at startup. The serial writer /// owns the active-session set and all trace I/O alongside diagnostic state. func registerSession(_ id: String, channel: String) { @@ -690,6 +731,8 @@ final class MacLogStore: ApplicationLogging, LogWindowLogging, LogSink, @uncheck } func finishSession() async { lock.withLock { stopped = true } + // The final drain below still collects facts already handed to Core. + stopObservingCoreFacts() // Termination bypasses the four-control admission limit and task cancellation. // Cleanup always runs on the I/O owner, including when draining fails. await withCheckedContinuation { (continuation: CheckedContinuation) in @@ -743,6 +786,18 @@ final class MacLogStore: ApplicationLogging, LogWindowLogging, LogSink, @uncheck } } +/// Retained by the Core registration; holds the store weakly so the waker +/// never extends the writer's lifetime. +private final class CoreLogWakeTarget: @unchecked Sendable { + weak var store: MacLogStore? + init(_ store: MacLogStore) { self.store = store } +} + +private let coreLogWake: RustCoreBridge.CoreLogWaker = { context in + guard let context else { return } + Unmanaged.fromOpaque(context).takeUnretainedValue().store?.scheduleCoreDrain() +} + private final class LogOperation: @unchecked Sendable { private let lock = NSLock() private var cancelled = false diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 01e1a33494..c8cf7b6b12 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -57,7 +57,10 @@ final class MacServiceContainer { } static func makeApplicationLogWriter(onDirectoryFallback: @escaping @Sendable () -> Void = {}) -> MacLogStore { - MacLogStore(onDirectoryFallback: onDirectoryFallback) + let writer = MacLogStore(onDirectoryFallback: onDirectoryFallback) + // Only the process writer owns Core's pending-fact signal. + writer.observeCoreFacts() + return writer } static func makeDiagnosticsExportService( diff --git a/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift b/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift index b0c97a0303..afc0886a07 100644 --- a/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift +++ b/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift @@ -96,12 +96,17 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi discardPendingRunInTerminalRequests() discardPendingStartDebuggingRequests() capabilities = .unknown - try transport.start(rootURL: rootURL.standardizedFileURL) + do { try transport.start(rootURL: rootURL.standardizedFileURL) } + catch { + logSink?.emit(DebugTransportFailed(stage: "process-start", operationID: sessionID)) + throw error + } if let initializationTimeoutMilliseconds { let token = UUID() initializationToken = token initializationDeadline = deadlineScheduler.schedule(afterMilliseconds: initializationTimeoutMilliseconds) { [weak self] in guard let self, self.initializationToken == token, self.ownsCoreSession else { return } + self.logSink?.emit(DebugTransportFailed(stage: "initialization-timeout", operationID: self.sessionID)) self.onEvent?(.output(category: "stderr", output: DebugAdapterProtocolError.timedOut("initialize").localizedDescription + "\n")) guard self.initializationToken == token else { return } self.failSession() @@ -853,6 +858,9 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi private func transportTerminated(exitCode: Int) { guard !isStopping else { return } + if exitCode != 0 && state != .failed { + logSink?.emit(DebugTransportFailed(exitCode: Int64(exitCode), stage: "transport-exit", operationID: sessionID)) + } cancelInitializationDeadline() finishErrorOutput() discardPendingRunInTerminalRequests() diff --git a/macos/Sources/LitheModuleAPI/Logging/LogEvents.generated.swift b/macos/Sources/LitheModuleAPI/Logging/LogEvents.generated.swift index 58b1202a3f..f3e9a5bbd8 100644 --- a/macos/Sources/LitheModuleAPI/Logging/LogEvents.generated.swift +++ b/macos/Sources/LitheModuleAPI/Logging/LogEvents.generated.swift @@ -1150,6 +1150,77 @@ public struct DebugInspectionFallbackSelected: LogEvent { } } +public struct DebugOperationCancelled: LogEvent { + public static let id = "debug.operation.cancelled" + public static let level: LogLevel = .debug + public static let messageTemplate = "Debug adapter operation discarded" + public static let ratePerMinute = 120 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`adapter`: LogSegment, `failureCode`: String, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.debug, segments: [`adapter`]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["adapter"] = .id(`adapter`) + _logValues["failure_code"] = .token(`failureCode`) + self.fields = _logValues + } +} + +public struct DebugOperationFailed: LogEvent { + public static let id = "debug.operation.failed" + public static let level: LogLevel = .error + public static let messageTemplate = "Debug adapter operation failed" + public static let ratePerMinute = 120 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`adapter`: LogSegment, `failureCode`: String, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.debug, segments: [`adapter`]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["adapter"] = .id(`adapter`) + _logValues["failure_code"] = .token(`failureCode`) + self.fields = _logValues + } +} + +public struct DebugProcessStarted: LogEvent { + public static let id = "debug.process.started" + public static let level: LogLevel = .info + public static let messageTemplate = "Debug adapter session created" + public static let ratePerMinute = 120 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`adapter`: LogSegment, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.debug, segments: [`adapter`]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["adapter"] = .id(`adapter`) + self.fields = _logValues + } +} + public struct DebugSessionCleanupFailed: LogEvent { public static let id = "debug.session.cleanup_failed" public static let level: LogLevel = .warn @@ -1174,6 +1245,100 @@ public struct DebugSessionCleanupFailed: LogEvent { } } +public struct DebugSessionFailed: LogEvent { + public static let id = "debug.session.failed" + public static let level: LogLevel = .error + public static let messageTemplate = "Debug adapter session failed" + public static let ratePerMinute = 120 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`adapter`: LogSegment, `failureCode`: String, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.debug, segments: [`adapter`]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["adapter"] = .id(`adapter`) + _logValues["failure_code"] = .token(`failureCode`) + self.fields = _logValues + } +} + +public struct DebugSessionFinished: LogEvent { + public static let id = "debug.session.finished" + public static let level: LogLevel = .info + public static let messageTemplate = "Debug adapter session terminated" + public static let ratePerMinute = 120 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`adapter`: LogSegment, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.debug, segments: [`adapter`]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["adapter"] = .id(`adapter`) + self.fields = _logValues + } +} + +public struct DebugSessionInitialized: LogEvent { + public static let id = "debug.session.initialized" + public static let level: LogLevel = .info + public static let messageTemplate = "Debug adapter session ready" + public static let ratePerMinute = 120 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`adapter`: LogSegment, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.debug, segments: [`adapter`]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["adapter"] = .id(`adapter`) + self.fields = _logValues + } +} + +public struct DebugSessionStateChanged: LogEvent { + public static let id = "debug.session.state_changed" + public static let level: LogLevel = .debug + public static let messageTemplate = "Debug adapter state changed" + public static let ratePerMinute = 120 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`adapter`: LogSegment, `state`: String, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.debug, segments: [`adapter`]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["adapter"] = .id(`adapter`) + _logValues["state"] = .token(`state`) + self.fields = _logValues + } +} + public struct DebugShortcutDecisionFailed: LogEvent { public static let id = "debug.shortcut.decision_failed" public static let level: LogLevel = .error @@ -1197,6 +1362,30 @@ public struct DebugShortcutDecisionFailed: LogEvent { } } +public struct DebugTransportFailed: LogEvent { + public static let id = "debug.transport.failed" + public static let level: LogLevel = .error + public static let messageTemplate = "Debug adapter transport failed" + public static let ratePerMinute = 60 + public static let owner = "LitheDebugModule" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`exitCode`: (Int64)? = nil, `stage`: String, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.debug, segments: []) + var _logValues: [String: LogFieldValue] = [:] + if let value = `exitCode` { _logValues["exit_code"] = .int(value) } + _logValues["stage"] = .token(`stage`) + self.fields = _logValues + } +} + public struct EditorDocumentHeaderFailed: LogEvent { public static let id = "editor.document.header_failed" public static let level: LogLevel = .warn @@ -2211,6 +2400,106 @@ public struct LspMavenTimedOut: LogEvent { } } +public struct LspMessageDetailLogged: LogEvent { + public static let id = "lsp.message.detail.logged" + public static let level: LogLevel = .debug + public static let messageTemplate = "Language server log message received for server {server} with severity {severity}" + public static let ratePerMinute = 120 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`diagnostic`: String, `server`: LogSegment, `severity`: String, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.lsp, segments: [`server`]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["diagnostic"] = .text(`diagnostic`) + _logValues["server"] = .id(`server`) + _logValues["severity"] = .token(`severity`) + self.fields = _logValues + } +} + +public struct LspMessageDetailShown: LogEvent { + public static let id = "lsp.message.detail.shown" + public static let level: LogLevel = .debug + public static let messageTemplate = "Language server show message received for server {server} with severity {severity}" + public static let ratePerMinute = 120 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`diagnostic`: String, `server`: LogSegment, `severity`: String, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.lsp, segments: [`server`]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["diagnostic"] = .text(`diagnostic`) + _logValues["server"] = .id(`server`) + _logValues["severity"] = .token(`severity`) + self.fields = _logValues + } +} + +public struct LspMessageErrorLogged: LogEvent { + public static let id = "lsp.message.error.logged" + public static let level: LogLevel = .error + public static let messageTemplate = "Language server log message received for server {server} with severity {severity}" + public static let ratePerMinute = 120 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`diagnostic`: String, `server`: LogSegment, `severity`: String, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.lsp, segments: [`server`]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["diagnostic"] = .text(`diagnostic`) + _logValues["server"] = .id(`server`) + _logValues["severity"] = .token(`severity`) + self.fields = _logValues + } +} + +public struct LspMessageErrorShown: LogEvent { + public static let id = "lsp.message.error.shown" + public static let level: LogLevel = .error + public static let messageTemplate = "Language server show message received for server {server} with severity {severity}" + public static let ratePerMinute = 120 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`diagnostic`: String, `server`: LogSegment, `severity`: String, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.lsp, segments: [`server`]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["diagnostic"] = .text(`diagnostic`) + _logValues["server"] = .id(`server`) + _logValues["severity"] = .token(`severity`) + self.fields = _logValues + } +} + public struct LspMessageLogged: LogEvent { public static let id = "lsp.message.logged" public static let level: LogLevel = .info @@ -2261,6 +2550,56 @@ public struct LspMessageShown: LogEvent { } } +public struct LspMessageWarningLogged: LogEvent { + public static let id = "lsp.message.warning.logged" + public static let level: LogLevel = .warn + public static let messageTemplate = "Language server log message received for server {server} with severity {severity}" + public static let ratePerMinute = 120 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`diagnostic`: String, `server`: LogSegment, `severity`: String, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.lsp, segments: [`server`]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["diagnostic"] = .text(`diagnostic`) + _logValues["server"] = .id(`server`) + _logValues["severity"] = .token(`severity`) + self.fields = _logValues + } +} + +public struct LspMessageWarningShown: LogEvent { + public static let id = "lsp.message.warning.shown" + public static let level: LogLevel = .warn + public static let messageTemplate = "Language server show message received for server {server} with severity {severity}" + public static let ratePerMinute = 120 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`diagnostic`: String, `server`: LogSegment, `severity`: String, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.lsp, segments: [`server`]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["diagnostic"] = .text(`diagnostic`) + _logValues["server"] = .id(`server`) + _logValues["severity"] = .token(`severity`) + self.fields = _logValues + } +} + public struct LspNavigationCacheHit: LogEvent { public static let id = "lsp.navigation.cache_hit" public static let level: LogLevel = .debug @@ -2513,6 +2852,31 @@ public struct LspSessionDegradationReceived: LogEvent { } } +public struct LspSessionFailed: LogEvent { + public static let id = "lsp.session.failed" + public static let level: LogLevel = .error + public static let messageTemplate = "Engine session failed" + public static let ratePerMinute = 60 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`code`: String, `server`: LogSegment, `state`: String, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.lsp, segments: [`server`]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["code"] = .token(`code`) + _logValues["server"] = .id(`server`) + _logValues["state"] = .token(`state`) + self.fields = _logValues + } +} + public struct LspSessionIssueFailed: LogEvent { public static let id = "lsp.session.issue_failed" public static let level: LogLevel = .error @@ -2636,7 +3000,7 @@ public struct LspStderrReadFailed: LogEvent { public struct LspStderrReceived: LogEvent { public static let id = "lsp.stderr.received" - public static let level: LogLevel = .warn + public static let level: LogLevel = .debug public static let messageTemplate = "Language server stderr received for server {server}" public static let ratePerMinute = 120 public static let owner = "lithe-core" @@ -3084,6 +3448,30 @@ public struct PluginsCatalogRejected: LogEvent { } } +public struct PluginsDiagnosticsRejected: LogEvent { + public static let id = "plugins.diagnostics.rejected" + public static let level: LogLevel = .warn + public static let messageTemplate = "Plugin diagnostic input rejected" + public static let ratePerMinute = 1 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`code`: String, `plugin`: LogSegment, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.plugins, segments: [`plugin`, LogSegment("host")!]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["code"] = .token(`code`) + _logValues["plugin"] = .id(`plugin`) + self.fields = _logValues + } +} + public struct PluginsEditorActionFailed: LogEvent { public static let id = "plugins.editor.action_failed" public static let level: LogLevel = .warn @@ -3206,6 +3594,106 @@ public struct PluginsGrammarRejected: LogEvent { } } +public struct PluginsHostMessageDetailLogged: LogEvent { + public static let id = "plugins.host.message.detail.logged" + public static let level: LogLevel = .debug + public static let messageTemplate = "Engine log message received for server {plugin} with severity {severity}" + public static let ratePerMinute = 120 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`diagnostic`: String, `plugin`: LogSegment, `severity`: String, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.plugins, segments: [`plugin`, LogSegment("host")!]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["diagnostic"] = .text(`diagnostic`) + _logValues["plugin"] = .id(`plugin`) + _logValues["severity"] = .token(`severity`) + self.fields = _logValues + } +} + +public struct PluginsHostMessageDetailShown: LogEvent { + public static let id = "plugins.host.message.detail.shown" + public static let level: LogLevel = .debug + public static let messageTemplate = "Engine show message received for server {plugin} with severity {severity}" + public static let ratePerMinute = 120 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`diagnostic`: String, `plugin`: LogSegment, `severity`: String, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.plugins, segments: [`plugin`, LogSegment("host")!]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["diagnostic"] = .text(`diagnostic`) + _logValues["plugin"] = .id(`plugin`) + _logValues["severity"] = .token(`severity`) + self.fields = _logValues + } +} + +public struct PluginsHostMessageErrorLogged: LogEvent { + public static let id = "plugins.host.message.error.logged" + public static let level: LogLevel = .error + public static let messageTemplate = "Engine log message received for server {plugin} with severity {severity}" + public static let ratePerMinute = 120 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`diagnostic`: String, `plugin`: LogSegment, `severity`: String, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.plugins, segments: [`plugin`, LogSegment("host")!]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["diagnostic"] = .text(`diagnostic`) + _logValues["plugin"] = .id(`plugin`) + _logValues["severity"] = .token(`severity`) + self.fields = _logValues + } +} + +public struct PluginsHostMessageErrorShown: LogEvent { + public static let id = "plugins.host.message.error.shown" + public static let level: LogLevel = .error + public static let messageTemplate = "Engine show message received for server {plugin} with severity {severity}" + public static let ratePerMinute = 120 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`diagnostic`: String, `plugin`: LogSegment, `severity`: String, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.plugins, segments: [`plugin`, LogSegment("host")!]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["diagnostic"] = .text(`diagnostic`) + _logValues["plugin"] = .id(`plugin`) + _logValues["severity"] = .token(`severity`) + self.fields = _logValues + } +} + public struct PluginsHostMessageLogged: LogEvent { public static let id = "plugins.host.message.logged" public static let level: LogLevel = .info @@ -3256,6 +3744,56 @@ public struct PluginsHostMessageShown: LogEvent { } } +public struct PluginsHostMessageWarningLogged: LogEvent { + public static let id = "plugins.host.message.warning.logged" + public static let level: LogLevel = .warn + public static let messageTemplate = "Engine log message received for server {plugin} with severity {severity}" + public static let ratePerMinute = 120 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`diagnostic`: String, `plugin`: LogSegment, `severity`: String, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.plugins, segments: [`plugin`, LogSegment("host")!]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["diagnostic"] = .text(`diagnostic`) + _logValues["plugin"] = .id(`plugin`) + _logValues["severity"] = .token(`severity`) + self.fields = _logValues + } +} + +public struct PluginsHostMessageWarningShown: LogEvent { + public static let id = "plugins.host.message.warning.shown" + public static let level: LogLevel = .warn + public static let messageTemplate = "Engine show message received for server {plugin} with severity {severity}" + public static let ratePerMinute = 120 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`diagnostic`: String, `plugin`: LogSegment, `severity`: String, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.plugins, segments: [`plugin`, LogSegment("host")!]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["diagnostic"] = .text(`diagnostic`) + _logValues["plugin"] = .id(`plugin`) + _logValues["severity"] = .token(`severity`) + self.fields = _logValues + } +} + public struct PluginsHostNotificationReceived: LogEvent { public static let id = "plugins.host.notification.received" public static let level: LogLevel = .info @@ -3389,12 +3927,37 @@ public struct PluginsHostResponseSkipped: LogEvent { LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) } - public init(`plugin`: LogSegment, `request`: (String)? = nil, operationID: String? = nil) { + public init(`plugin`: LogSegment, `request`: (String)? = nil, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.plugins, segments: [`plugin`, LogSegment("host")!]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["plugin"] = .id(`plugin`) + if let value = `request` { _logValues["request"] = .token(value) } + self.fields = _logValues + } +} + +public struct PluginsHostSessionFailed: LogEvent { + public static let id = "plugins.host.session.failed" + public static let level: LogLevel = .error + public static let messageTemplate = "Engine session failed" + public static let ratePerMinute = 60 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`code`: String, `plugin`: LogSegment, `state`: String, operationID: String? = nil) { self.operationID = operationID self.channel = LogChannel(.plugins, segments: [`plugin`, LogSegment("host")!]) var _logValues: [String: LogFieldValue] = [:] + _logValues["code"] = .token(`code`) _logValues["plugin"] = .id(`plugin`) - if let value = `request` { _logValues["request"] = .token(value) } + _logValues["state"] = .token(`state`) self.fields = _logValues } } @@ -3472,7 +4035,7 @@ public struct PluginsHostStderrReadFailed: LogEvent { public struct PluginsHostStderrReceived: LogEvent { public static let id = "plugins.host.stderr.received" - public static let level: LogLevel = .warn + public static let level: LogLevel = .debug public static let messageTemplate = "Engine stderr received for server {plugin}" public static let ratePerMinute = 120 public static let owner = "lithe-core" @@ -3639,6 +4202,111 @@ public struct PluginsMigrationStateFailed: LogEvent { } } +public struct PluginsOperationFailed: LogEvent { + public static let id = "plugins.operation.failed" + public static let level: LogLevel = .error + public static let messageTemplate = "Plugin operation failed" + public static let ratePerMinute = 600 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`code`: String, `count`: (Int64)? = nil, `duration`: Duration, `operation`: String, `plugin`: LogSegment, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.plugins, segments: [`plugin`, LogSegment("host")!]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["code"] = .token(`code`) + if let value = `count` { _logValues["count"] = .int(value) } + _logValues["duration"] = .duration(`duration`) + _logValues["operation"] = .token(`operation`) + _logValues["plugin"] = .id(`plugin`) + self.fields = _logValues + } +} + +public struct PluginsOperationFallbackSelected: LogEvent { + public static let id = "plugins.operation.fallback_selected" + public static let level: LogLevel = .warn + public static let messageTemplate = "Plugin operation used a degraded result" + public static let ratePerMinute = 600 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`code`: String, `count`: (Int64)? = nil, `operation`: String, `plugin`: LogSegment, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.plugins, segments: [`plugin`, LogSegment("host")!]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["code"] = .token(`code`) + if let value = `count` { _logValues["count"] = .int(value) } + _logValues["operation"] = .token(`operation`) + _logValues["plugin"] = .id(`plugin`) + self.fields = _logValues + } +} + +public struct PluginsOperationFinished: LogEvent { + public static let id = "plugins.operation.finished" + public static let level: LogLevel = .info + public static let messageTemplate = "Plugin operation finished" + public static let ratePerMinute = 600 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`count`: (Int64)? = nil, `duration`: Duration, `operation`: String, `plugin`: LogSegment, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.plugins, segments: [`plugin`, LogSegment("host")!]) + var _logValues: [String: LogFieldValue] = [:] + if let value = `count` { _logValues["count"] = .int(value) } + _logValues["duration"] = .duration(`duration`) + _logValues["operation"] = .token(`operation`) + _logValues["plugin"] = .id(`plugin`) + self.fields = _logValues + } +} + +public struct PluginsOperationRecoveryFinished: LogEvent { + public static let id = "plugins.operation.recovery_finished" + public static let level: LogLevel = .warn + public static let messageTemplate = "Plugin operation recovered" + public static let ratePerMinute = 600 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`code`: String, `count`: (Int64)? = nil, `operation`: String, `plugin`: LogSegment, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.plugins, segments: [`plugin`, LogSegment("host")!]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["code"] = .token(`code`) + if let value = `count` { _logValues["count"] = .int(value) } + _logValues["operation"] = .token(`operation`) + _logValues["plugin"] = .id(`plugin`) + self.fields = _logValues + } +} + public struct PluginsResourcesMaintenanceFailed: LogEvent { public static let id = "plugins.resources.maintenance_failed" public static let level: LogLevel = .warn @@ -4729,6 +5397,106 @@ public struct VcsHostCleanupFailed: LogEvent { } } +public struct VcsMessageDetailLogged: LogEvent { + public static let id = "vcs.message.detail.logged" + public static let level: LogLevel = .debug + public static let messageTemplate = "Engine log message received for server {provider} with severity {severity}" + public static let ratePerMinute = 120 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`diagnostic`: String, `provider`: LogSegment, `severity`: String, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.vcs, segments: [`provider`]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["diagnostic"] = .text(`diagnostic`) + _logValues["provider"] = .id(`provider`) + _logValues["severity"] = .token(`severity`) + self.fields = _logValues + } +} + +public struct VcsMessageDetailShown: LogEvent { + public static let id = "vcs.message.detail.shown" + public static let level: LogLevel = .debug + public static let messageTemplate = "Engine show message received for server {provider} with severity {severity}" + public static let ratePerMinute = 120 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`diagnostic`: String, `provider`: LogSegment, `severity`: String, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.vcs, segments: [`provider`]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["diagnostic"] = .text(`diagnostic`) + _logValues["provider"] = .id(`provider`) + _logValues["severity"] = .token(`severity`) + self.fields = _logValues + } +} + +public struct VcsMessageErrorLogged: LogEvent { + public static let id = "vcs.message.error.logged" + public static let level: LogLevel = .error + public static let messageTemplate = "Engine log message received for server {provider} with severity {severity}" + public static let ratePerMinute = 120 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`diagnostic`: String, `provider`: LogSegment, `severity`: String, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.vcs, segments: [`provider`]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["diagnostic"] = .text(`diagnostic`) + _logValues["provider"] = .id(`provider`) + _logValues["severity"] = .token(`severity`) + self.fields = _logValues + } +} + +public struct VcsMessageErrorShown: LogEvent { + public static let id = "vcs.message.error.shown" + public static let level: LogLevel = .error + public static let messageTemplate = "Engine show message received for server {provider} with severity {severity}" + public static let ratePerMinute = 120 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`diagnostic`: String, `provider`: LogSegment, `severity`: String, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.vcs, segments: [`provider`]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["diagnostic"] = .text(`diagnostic`) + _logValues["provider"] = .id(`provider`) + _logValues["severity"] = .token(`severity`) + self.fields = _logValues + } +} + public struct VcsMessageLogged: LogEvent { public static let id = "vcs.message.logged" public static let level: LogLevel = .info @@ -4779,6 +5547,56 @@ public struct VcsMessageShown: LogEvent { } } +public struct VcsMessageWarningLogged: LogEvent { + public static let id = "vcs.message.warning.logged" + public static let level: LogLevel = .warn + public static let messageTemplate = "Engine log message received for server {provider} with severity {severity}" + public static let ratePerMinute = 120 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`diagnostic`: String, `provider`: LogSegment, `severity`: String, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.vcs, segments: [`provider`]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["diagnostic"] = .text(`diagnostic`) + _logValues["provider"] = .id(`provider`) + _logValues["severity"] = .token(`severity`) + self.fields = _logValues + } +} + +public struct VcsMessageWarningShown: LogEvent { + public static let id = "vcs.message.warning.shown" + public static let level: LogLevel = .warn + public static let messageTemplate = "Engine show message received for server {provider} with severity {severity}" + public static let ratePerMinute = 120 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`diagnostic`: String, `provider`: LogSegment, `severity`: String, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.vcs, segments: [`provider`]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["diagnostic"] = .text(`diagnostic`) + _logValues["provider"] = .id(`provider`) + _logValues["severity"] = .token(`severity`) + self.fields = _logValues + } +} + public struct VcsNotificationReceived: LogEvent { public static let id = "vcs.notification.received" public static let level: LogLevel = .info @@ -4922,6 +5740,31 @@ public struct VcsRootsFallbackSelected: LogEvent { } } +public struct VcsSessionFailed: LogEvent { + public static let id = "vcs.session.failed" + public static let level: LogLevel = .error + public static let messageTemplate = "Engine session failed" + public static let ratePerMinute = 60 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`code`: String, `provider`: LogSegment, `state`: String, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.vcs, segments: [`provider`]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["code"] = .token(`code`) + _logValues["provider"] = .id(`provider`) + _logValues["state"] = .token(`state`) + self.fields = _logValues + } +} + public struct VcsSessionUpdated: LogEvent { public static let id = "vcs.session.updated" public static let level: LogLevel = .debug @@ -5019,7 +5862,7 @@ public struct VcsStderrReadFailed: LogEvent { public struct VcsStderrReceived: LogEvent { public static let id = "vcs.stderr.received" - public static let level: LogLevel = .warn + public static let level: LogLevel = .debug public static let messageTemplate = "Engine stderr received for server {provider}" public static let ratePerMinute = 120 public static let owner = "lithe-core" diff --git a/macos/Sources/LitheRustCore/bridge.c b/macos/Sources/LitheRustCore/bridge.c index bdc136ae95..a738949d34 100644 --- a/macos/Sources/LitheRustCore/bridge.c +++ b/macos/Sources/LitheRustCore/bridge.c @@ -54,6 +54,20 @@ void lithe_bridge_free_string(char *value) { lithe_core_free_string(value); } +__attribute__((weak)) void lithe_core_install_log_waker(void (*callback)(void *), void *context) { + (void)callback; (void)context; +} +__attribute__((weak)) int32_t lithe_core_remove_log_waker(void *context) { + (void)context; + return 0; +} +void lithe_bridge_install_log_waker(void (*callback)(void *), void *context) { + lithe_core_install_log_waker(callback, context); +} +int32_t lithe_bridge_remove_log_waker(void *context) { + return lithe_core_remove_log_waker(context); +} + __attribute__((weak)) char *lithe_core_execute_json_with_events(const char *request, void (*callback)(const char *, void *), void *context) { (void)callback; (void)context; diff --git a/macos/Sources/LitheRustCore/include/lithe_bridge.h b/macos/Sources/LitheRustCore/include/lithe_bridge.h index 7fb68a78b8..90ca3cb86a 100644 --- a/macos/Sources/LitheRustCore/include/lithe_bridge.h +++ b/macos/Sources/LitheRustCore/include/lithe_bridge.h @@ -17,6 +17,8 @@ char *lithe_bridge_execute_json_with_events(const char *request, void (*callback char *lithe_bridge_lsp_provider_catalog_json(const char *workspace_root); int32_t lithe_bridge_cancel(const char *operation_id); void lithe_bridge_free_string(char *value); +void lithe_bridge_install_log_waker(void (*callback)(void *), void *context); +int32_t lithe_bridge_remove_log_waker(void *context); void *lithe_bridge_agent_open_json(const char *configuration, void (*callback)(const char *, void *), void *context); int32_t lithe_bridge_agent_send_json(void *handle, const char *command); void lithe_bridge_agent_close(void *handle); diff --git a/macos/Tests/LitheTests/MacLogStoreTests.swift b/macos/Tests/LitheTests/MacLogStoreTests.swift index 1cea21bd11..45e5d1d350 100644 --- a/macos/Tests/LitheTests/MacLogStoreTests.swift +++ b/macos/Tests/LitheTests/MacLogStoreTests.swift @@ -31,8 +31,8 @@ struct MacLogStoreTests { #expect(throws: (any Error).self) { try writer.pluginFilesDirectory(pluginID: "dev.lithe.fixture") } #expect(throws: (any Error).self) { try writer.pluginFilesDirectory(pluginID: "../escape") } let stamp = "2026-11-03T00:00:00.000Z" - writer.emit(CoreLogRecord(event: "vcs.stderr.received", level: "WARN", channel: "vcs/git", op: "fixture-op", - fields: ["provider": .string("git"), "diagnostic": .string("fixture diagnostic"), "truncated": .bool(false)], ts: stamp)) + writer.emit(CoreLogRecord(event: "vcs.message.warning.logged", level: "WARN", channel: "vcs/git", op: "fixture-op", + fields: ["provider": .string("git"), "diagnostic": .string("fixture diagnostic"), "severity": .string("warn")], ts: stamp)) writer.emit(CoreLogRecord(event: "plugins.host.message.logged", level: "INFO", channel: "plugins/dev.lithe.fixture/host", fields: ["plugin": .string("dev.lithe.fixture"), "diagnostic": .string("backend diagnostic"), "severity": .string("info")])) let collection = try await writer.collect(query: .init(session: "all")) @@ -43,34 +43,45 @@ struct MacLogStoreTests { await writer.finishSession() } - @Test - func flushDrainsRealWorkspaceFailureFromCoreOnlyOnce() async throws { - // Core's pending facts belong to the process. Run this real Core-to-file - // boundary alone so another store's scheduled drain cannot steal them. - if ProcessInfo.processInfo.environment["LITHE_CORE_LOG_DRAIN_CHILD"] != "1" { - let image = try #require(Bundle(for: CoreLogDrainBundleMarker.self).executableURL) - var environment = ProcessInfo.processInfo.environment - environment["LITHE_CORE_LOG_DRAIN_CHILD"] = "1" - let result = try await TestProcess.run( - executableURL: URL(fileURLWithPath: CommandLine.arguments[0]), - arguments: ["--test-bundle-path", image.path, "--testing-library", "swift-testing", "--filter", - "LitheTests.MacLogStoreTests/flushDrainsRealWorkspaceFailureFromCoreOnlyOnce"], - currentDirectoryURL: FileManager.default.temporaryDirectory, - environment: environment, timeout: .seconds(10)) - #expect(result.terminationStatus == 0, "Isolated Core drain failed: \(String(decoding: result.output, as: UTF8.self))") - #expect(String(decoding: result.output, as: UTF8.self).contains("Test run with 1 test in 1 suite passed")) - return - } + /// Core's pending facts and waker belong to the process. Real Core-to-file + /// boundaries run alone in a child test process so another store's drain + /// cannot steal their facts. Returns true only inside that child. + private func runsInIsolatedCoreProcess(_ test: String) async throws -> Bool { + if ProcessInfo.processInfo.environment["LITHE_CORE_LOG_DRAIN_CHILD"] == test { return true } + let image = try #require(Bundle(for: CoreLogDrainBundleMarker.self).executableURL) + var environment = ProcessInfo.processInfo.environment + environment["LITHE_CORE_LOG_DRAIN_CHILD"] = test + let result = try await TestProcess.run( + executableURL: URL(fileURLWithPath: CommandLine.arguments[0]), + arguments: ["--test-bundle-path", image.path, "--testing-library", "swift-testing", "--filter", + "LitheTests.MacLogStoreTests/\(test)"], + currentDirectoryURL: FileManager.default.temporaryDirectory, + environment: environment, timeout: .seconds(10)) + #expect(result.terminationStatus == 0, "Isolated Core drain failed: \(String(decoding: result.output, as: UTF8.self))") + #expect(String(decoding: result.output, as: UTF8.self).contains("Test run with 1 test in 1 suite passed")) + return false + } + + /// A workspace whose unreadable child makes a real snapshot record + /// `app.workspace.directory_skipped` in Core's process queue. + private func unreadableWorkspace() throws -> (root: URL, workspace: URL, cleanup: () -> Void) { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let workspace = root.appendingPathComponent("workspace") let hidden = workspace.appendingPathComponent("inaccessible") try FileManager.default.createDirectory(at: hidden, withIntermediateDirectories: true) - defer { - try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: hidden.path) - try? FileManager.default.removeItem(at: root) - } try Data("evidence".utf8).write(to: hidden.appendingPathComponent("file")) try FileManager.default.setAttributes([.posixPermissions: 0], ofItemAtPath: hidden.path) + return (root, workspace, { + try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: hidden.path) + try? FileManager.default.removeItem(at: root) + }) + } + + @Test + func flushDrainsRealWorkspaceFailureFromCoreOnlyOnce() async throws { + guard try await runsInIsolatedCoreProcess("flushDrainsRealWorkspaceFailureFromCoreOnlyOnce") else { return } + let (root, workspace, cleanup) = try unreadableWorkspace() + defer { cleanup() } let writer = MacLogStore(defaultDirectory: root.appendingPathComponent("logs")) _ = try RustCoreBridge().snapshot(at: workspace) try await writer.flush() @@ -84,6 +95,38 @@ struct MacLogStoreTests { await writer.finishSession() } + @Test + func coreFactsReachDiskAndSubscribersWithoutHostActivity() async throws { + guard try await runsInIsolatedCoreProcess("coreFactsReachDiskAndSubscribersWithoutHostActivity") else { return } + let (root, workspace, cleanup) = try unreadableWorkspace() + defer { cleanup() } + let logs = root.appendingPathComponent("logs") + let writer = MacLogStore(defaultDirectory: logs) + writer.observeCoreFacts() + let stream = try await writer.subscribe() + let first = TestGate(), second = TestGate() + let consumer = Task { + var deliveries = 0 + for await batch in stream where batch.contains(where: { $0.line.contains("event=app.workspace.directory_skipped") }) { + deliveries += 1 + if deliveries == 1 { first.open() } else { second.open(); break } + } + } + defer { consumer.cancel() } + // No emit, flush or collect follows: only Core's signal can start these + // drains. The second fact proves the signal re-arms after each drain. + _ = try RustCoreBridge().snapshot(at: workspace) + #expect(await first.waitUntilOpen()) + _ = try RustCoreBridge().snapshot(at: workspace) + #expect(await second.waitUntilOpen()) + let files = FileManager.default.enumerator(at: logs, includingPropertiesForKeys: nil)?.allObjects as? [URL] ?? [] + let disk = try files.filter { $0.pathExtension == "log" } + .map { try String(contentsOf: $0, encoding: .utf8) }.joined() + #expect(disk.components(separatedBy: "event=app.workspace.directory_skipped").count - 1 >= 2) + await writer.finishSession() + await consumer.value + } + @Test func collectionAndCleanupPreserveUnrelatedLogFiles() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) @@ -342,7 +385,7 @@ extension MacLogStoreTests { try Data("old evidence".utf8).write(to: expired.appendingPathComponent("app.log")) for index in 0..<35 { let server = try #require(LogSegment("server-\(index)")) - writer.emit(LspStderrReceived(diagnostic: "upstream warning", server: server)) + writer.emit(LspMessageWarningLogged(diagnostic: "upstream warning", server: server, severity: "warn")) } let snapshot = try await writer.collect(query: .init()) #expect(!FileManager.default.fileExists(atPath: expired.path)) @@ -441,9 +484,10 @@ extension MacLogStoreTests { let writer = MacLogStore(fileLimit: 550) try writer.redirect(to: root) let foo = LogSegment("foo")!, dotted = LogSegment("foo.1")! - writer.emit(LspStderrReceived(diagnostic: "dotted-before", server: dotted)) - for index in 0..<4 { writer.emit(LspStderrReceived(diagnostic: "foo-\(index)", server: foo)) } - writer.emit(LspStderrReceived(diagnostic: "dotted-after", server: dotted)) + // WARN facts persist without diagnostic mode; stderr is DEBUG detail. + writer.emit(LspMessageWarningLogged(diagnostic: "dotted-before", server: dotted, severity: "warn")) + for index in 0..<4 { writer.emit(LspMessageWarningLogged(diagnostic: "foo-\(index)", server: foo, severity: "warn")) } + writer.emit(LspMessageWarningLogged(diagnostic: "dotted-after", server: dotted, severity: "warn")) let snapshot = try await writer.collect(query: .init()) #expect(snapshot.writeFailures == 0) #expect(snapshot.content.contains("dotted-before")) diff --git a/macos/Tests/LitheTests/PluginSettingsTests.swift b/macos/Tests/LitheTests/PluginSettingsTests.swift index 86718ac18c..e0e88d77e3 100644 --- a/macos/Tests/LitheTests/PluginSettingsTests.swift +++ b/macos/Tests/LitheTests/PluginSettingsTests.swift @@ -106,14 +106,14 @@ struct PluginSettingsTests { #expect(descriptor.settings.map(\.id) == ["heapMaxMB", "logLevel"]) let heap = try #require(descriptor.settings.first { $0.id == "heapMaxMB" }) - // The language server starts with a 2 GB heap and full logging. A smaller - // heap makes JDT LS import Maven modules in its constrained-memory mode, - // which fails the whole Java service for modules that share coordinates, - // so the plugin does not offer one. + // The language server starts with a 2 GB heap and logs warnings and + // errors only. A smaller heap makes JDT LS import Maven modules in its + // constrained-memory mode, which fails the whole Java service for modules + // that share coordinates, so the plugin does not offer one. #expect(heap.defaultValue == .integer(2048)) #expect(heap.kind == .integer(minimum: 2048, maximum: 16384, unit: "MB")) let level = try #require(descriptor.settings.first { $0.id == "logLevel" }) - #expect(level.defaultValue == .string("ALL")) + #expect(level.defaultValue == .string("WARNING")) #expect(level.accepts(.string("SEVERE")) && !level.accepts(.string("OFF"))) } @@ -135,7 +135,8 @@ struct PluginSettingsTests { stateDirectory: URL(fileURLWithPath: "/state"), workspaceStateDirectory: URL(fileURLWithPath: "/state/workspace"), architecture: MacPluginLanguageServerLaunchResolver.hostArchitecture, - settings: settings.values(forPlugin: descriptor.id.rawValue) + settings: settings.values(forPlugin: descriptor.id.rawValue), + logDirectory: URL(fileURLWithPath: "/logs") ) let plan = try DeclarativePluginTestSupport.operations.languageServerLaunchPlan( @@ -145,6 +146,8 @@ struct PluginSettingsTests { ) #expect(plan.arguments.prefix(2) == ["-Xmx3072m", "-Dlog.level=WARNING"]) + // JDT LS keeps its own log in Lithe's log directory, never in plugin state. + #expect(plan.arguments.contains("-Dosgi.logfile=/logs/jdt.log")) } // MARK: Settings v2: layers, pages, and buttons diff --git a/rust/lithe-core/include/lithe_core.h b/rust/lithe-core/include/lithe_core.h index 9ed77a9936..6f70ef3aea 100644 --- a/rust/lithe-core/include/lithe_core.h +++ b/rust/lithe-core/include/lithe_core.h @@ -17,6 +17,8 @@ char *lithe_core_execute_json_with_events(const char *request, void (*callback)( char *lithe_core_lsp_provider_catalog_json(const char *workspace_root); int32_t lithe_core_cancel(const char *operation_id); void lithe_core_free_string(char *value); +void lithe_core_install_log_waker(void (*callback)(void *), void *context); +int32_t lithe_core_remove_log_waker(void *context); void *lithe_agent_open_json(const char *configuration, void (*callback)(const char *, void *), void *context); int32_t lithe_agent_send_json(void *handle, const char *command); void lithe_agent_close(void *handle); diff --git a/rust/lithe-core/src/debug/engine.rs b/rust/lithe-core/src/debug/engine.rs index eb7057faea..1e4e523561 100644 --- a/rust/lithe-core/src/debug/engine.rs +++ b/rust/lithe-core/src/debug/engine.rs @@ -12,6 +12,7 @@ use crate::protocol::{CoreError, ErrorCode}; use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine as _; use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::{Mutex, OnceLock}; @@ -21,6 +22,10 @@ static SESSIONS: OnceLock>> = OnceLock::new( struct DebugSession { id: String, adapter_id: String, + /// Catalog-safe adapter identity; never derived from DAP payload or user output. + log_adapter: String, + /// A failing launch operation already owns the session failure record. + logged_terminal_failure: bool, root_path: String, state: DebugSessionState, next_request_sequence: i64, @@ -139,6 +144,8 @@ pub(crate) fn create_session( } let mut session = DebugSession { id: request.session_id.clone(), + log_adapter: debug_log_adapter(&request.adapter_id), + logged_terminal_failure: false, adapter_id: request.adapter_id, root_path: request.root_path, state: DebugSessionState::Idle, @@ -176,6 +183,11 @@ pub(crate) fn create_session( outbound_frames: Vec::new(), events: Vec::new(), }; + session.log_fact( + "debug.process.started", + BTreeMap::new(), + Some(session.id.clone()), + ); session.transition(DebugSessionState::Initializing); let mut initialize = json!({ "clientID": "lithe", @@ -848,12 +860,17 @@ pub(crate) fn receive(request: ReceiveRequest) -> Result messages, Err(error) => { + session.log_session_failure("protocol"); session.transition(DebugSessionState::Failed); return Err(error); } }; for message in messages { - session.handle_message(message)?; + if let Err(error) = session.handle_message(message) { + session.log_session_failure("protocol"); + session.transition(DebugSessionState::Failed); + return Err(error); + } } Ok(session.take_update()) }) @@ -970,9 +987,10 @@ pub(crate) fn disconnect(request: SessionRequest) -> Result Result, CoreError> { let mut sessions = sessions_lock()?; - let Some(removed) = sessions.remove(&request.session_id) else { + let Some(mut removed) = sessions.remove(&request.session_id) else { return Err(session_not_found(&request.session_id)); }; + removed.transition(DebugSessionState::Terminated); if let Some(parent) = removed .parent_id .as_ref() @@ -984,7 +1002,8 @@ pub(crate) fn destroy_session(request: SessionRequest) -> Result, Co let mut descendants = Vec::new(); let mut pending = removed.child_ids; while let Some(child_id) = pending.pop() { - if let Some(child) = sessions.remove(&child_id) { + if let Some(mut child) = sessions.remove(&child_id) { + child.transition(DebugSessionState::Terminated); pending.extend(child.child_ids); descendants.push(child_id); } @@ -1791,6 +1810,25 @@ impl DebugSession { return; } self.state = state; + let event = match state { + DebugSessionState::Ready => "debug.session.initialized", + DebugSessionState::Terminated => "debug.session.finished", + DebugSessionState::Failed => { + self.log_session_failure("adapter_rejected"); + self.emit(DebugEventBody::StateChanged { state }); + return; + } + _ => "debug.session.state_changed", + }; + let fields = if event == "debug.session.state_changed" { + BTreeMap::from([( + "state".into(), + serde_json::to_value(state).expect("DAP state encodes"), + )]) + } else { + BTreeMap::new() + }; + self.log_fact(event, fields, Some(self.id.clone())); self.emit(DebugEventBody::StateChanged { state }); } @@ -1844,7 +1882,52 @@ impl DebugSession { } } + fn log_fact(&self, event: &str, mut fields: BTreeMap, op: Option) { + fields.insert("adapter".into(), json!(self.log_adapter)); + if crate::diagnostics::core_events::record(event, fields, op).is_err() { + crate::diagnostics::core_events::note_dropped(); + } + } + + fn log_session_failure(&mut self, code: &str) { + if self.logged_terminal_failure { + return; + } + self.logged_terminal_failure = true; + self.log_fact( + "debug.session.failed", + BTreeMap::from([("failure_code".into(), json!(code))]), + Some(self.id.clone()), + ); + } + fn emit(&mut self, body: DebugEventBody) { + if let DebugEventBody::OperationFailed { + operation_id, + command, + code, + .. + } = &body + { + let (event, failure) = match code { + DebugOperationFailureCode::Cancelled => ("debug.operation.cancelled", "cancelled"), + DebugOperationFailureCode::StaleReference => { + ("debug.operation.cancelled", "stale_reference") + } + DebugOperationFailureCode::TimedOut => ("debug.operation.failed", "timed_out"), + DebugOperationFailureCode::AdapterRejected => { + ("debug.operation.failed", "adapter_rejected") + } + }; + // A launch cancellation is an expected terminal too: the following + // Failed state must not turn it into a second product error. + self.logged_terminal_failure |= matches!(command.as_str(), "launch" | "attach"); + self.log_fact( + event, + BTreeMap::from([("failure_code".into(), json!(failure))]), + Some(operation_id.clone()), + ); + } let sequence = self.next_event_sequence; self.next_event_sequence += 1; self.events.push(DebugEvent { sequence, body }); @@ -1863,6 +1946,14 @@ impl DebugSession { } } +fn debug_log_adapter(adapter: &str) -> String { + if crate::diagnostics::channel::validate_segment(adapter).is_ok() { + adapter.to_owned() + } else { + format!("invalid-{:x}", Sha256::digest(adapter.as_bytes()))[..40].to_owned() + } +} + impl PendingRequest { fn command(&self) -> &'static str { match self { @@ -3058,6 +3149,59 @@ mod tests { serde_json::from_slice(&bytes[body_start..]).unwrap() } + #[test] + fn dap_logging_keeps_catalog_identity_and_cancellation_private() { + let adapter = "lldb+private-adapter"; + let subject = debug_log_adapter(adapter); + assert!(subject.starts_with("invalid-")); + assert_eq!(subject, debug_log_adapter(adapter)); + assert!(!subject.contains("private")); + let mut fact = crate::diagnostics::record::StructuredLogEvent { + ts: "2026-11-04T00:00:00.000Z".into(), + level: crate::diagnostics::record::LogLevel::Debug, + seq: 1, + event: "debug.operation.cancelled".into(), + op: Some("owned-operation".into()), + msg: String::new(), + fields: BTreeMap::from([ + ("adapter".into(), json!(subject)), + ("failure_code".into(), json!("cancelled")), + ]), + }; + let channel = + crate::diagnostics::record::prepare_catalog_event(&mut fact, &json!({})).unwrap(); + assert_eq!(fact.level, crate::diagnostics::record::LogLevel::Debug); + assert_eq!(fact.op.as_deref(), Some("owned-operation")); + assert_eq!(fact.fields.len(), 2); + assert!(channel.starts_with("debug/invalid-")); + fact.fields + .insert("message".into(), json!("forbidden protocol payload")); + assert!(crate::diagnostics::record::prepare_catalog_event(&mut fact, &json!({})).is_err()); + } + + #[test] + fn dap_logging_failed_launch_owns_one_terminal_and_cancel_is_not_error() { + let session_id = "debug-log-owner"; + create_session(child_request(session_id, None)).unwrap(); + let result = with_session(session_id, |session| { + session.state = DebugSessionState::Launching; + session.emit(DebugEventBody::OperationFailed { + operation_id: "cancelled-launch".into(), + command: "launch".into(), + code: DebugOperationFailureCode::Cancelled, + message: "private adapter payload must never be logged".into(), + }); + assert!(session.logged_terminal_failure); + session.transition(DebugSessionState::Failed); + Ok(()) + }); + destroy_session(SessionRequest { + session_id: session_id.into(), + }) + .unwrap(); + result.unwrap(); + } + #[test] fn stack_frame_reports_adapter_served_source_references() { let filters = DebugSteppingFilters::defaults_for_adapter("lldb"); diff --git a/rust/lithe-core/src/diagnostics/catalog_generated.rs b/rust/lithe-core/src/diagnostics/catalog_generated.rs index f618b73fce..31129a8acc 100644 --- a/rust/lithe-core/src/diagnostics/catalog_generated.rs +++ b/rust/lithe-core/src/diagnostics/catalog_generated.rs @@ -627,6 +627,44 @@ pub static EVENTS: &[EventMetadata] = &[ rate: 60, owner: "LitheDebugModule", }, + EventMetadata { + id: "debug.operation.cancelled", + channel: "debug/{adapter}", + level: LogLevel::Debug, + message: "Debug adapter operation discarded", + fields: &[ + FieldMetadata { name: "adapter", field_type: FieldType::Id, required: true, description: "Validated DAP adapter identity selected by the debug session; unsafe names use a stable SHA-256 segment", reason: None }, + FieldMetadata { name: "failure_code", field_type: FieldType::Token, required: true, description: "Expected terminal classification, cancelled or stale_reference; no protocol payload", reason: None }, + ], + span: None, + rate: 120, + owner: "lithe-core", + }, + EventMetadata { + id: "debug.operation.failed", + channel: "debug/{adapter}", + level: LogLevel::Error, + message: "Debug adapter operation failed", + fields: &[ + FieldMetadata { name: "adapter", field_type: FieldType::Id, required: true, description: "Validated DAP adapter identity selected by the debug session; unsafe names use a stable SHA-256 segment", reason: None }, + FieldMetadata { name: "failure_code", field_type: FieldType::Token, required: true, description: "Stable Core operation failure classification, adapter_rejected or timed_out; no adapter message", reason: None }, + ], + span: None, + rate: 120, + owner: "lithe-core", + }, + EventMetadata { + id: "debug.process.started", + channel: "debug/{adapter}", + level: LogLevel::Info, + message: "Debug adapter session created", + fields: &[ + FieldMetadata { name: "adapter", field_type: FieldType::Id, required: true, description: "Validated DAP adapter identity selected by the debug session; unsafe names use a stable SHA-256 segment", reason: None }, + ], + span: None, + rate: 120, + owner: "lithe-core", + }, EventMetadata { id: "debug.session.cleanup_failed", channel: "debug", @@ -640,6 +678,56 @@ pub static EVENTS: &[EventMetadata] = &[ rate: 60, owner: "LitheDebugModule", }, + EventMetadata { + id: "debug.session.failed", + channel: "debug/{adapter}", + level: LogLevel::Error, + message: "Debug adapter session failed", + fields: &[ + FieldMetadata { name: "adapter", field_type: FieldType::Id, required: true, description: "Validated DAP adapter identity selected by the debug session; unsafe names use a stable SHA-256 segment", reason: None }, + FieldMetadata { name: "failure_code", field_type: FieldType::Token, required: true, description: "Controlled session failure category, protocol or adapter_rejected; no adapter response text", reason: None }, + ], + span: None, + rate: 120, + owner: "lithe-core", + }, + EventMetadata { + id: "debug.session.finished", + channel: "debug/{adapter}", + level: LogLevel::Info, + message: "Debug adapter session terminated", + fields: &[ + FieldMetadata { name: "adapter", field_type: FieldType::Id, required: true, description: "Validated DAP adapter identity selected by the debug session; unsafe names use a stable SHA-256 segment", reason: None }, + ], + span: None, + rate: 120, + owner: "lithe-core", + }, + EventMetadata { + id: "debug.session.initialized", + channel: "debug/{adapter}", + level: LogLevel::Info, + message: "Debug adapter session ready", + fields: &[ + FieldMetadata { name: "adapter", field_type: FieldType::Id, required: true, description: "Validated DAP adapter identity selected by the debug session; unsafe names use a stable SHA-256 segment", reason: None }, + ], + span: None, + rate: 120, + owner: "lithe-core", + }, + EventMetadata { + id: "debug.session.state_changed", + channel: "debug/{adapter}", + level: LogLevel::Debug, + message: "Debug adapter state changed", + fields: &[ + FieldMetadata { name: "adapter", field_type: FieldType::Id, required: true, description: "Validated DAP adapter identity selected by the debug session; unsafe names use a stable SHA-256 segment", reason: None }, + FieldMetadata { name: "state", field_type: FieldType::Token, required: true, description: "Core DAP lifecycle state reduced from the protocol, such as initializing, ready, running or terminated", reason: None }, + ], + span: None, + rate: 120, + owner: "lithe-core", + }, EventMetadata { id: "debug.shortcut.decision_failed", channel: "debug", @@ -652,6 +740,19 @@ pub static EVENTS: &[EventMetadata] = &[ rate: 60, owner: "Lithe", }, + EventMetadata { + id: "debug.transport.failed", + channel: "debug", + level: LogLevel::Error, + message: "Debug adapter transport failed", + fields: &[ + FieldMetadata { name: "exit_code", field_type: FieldType::Int, required: false, description: "Native adapter process exit status when available; absent for startup, timeout and receive failures", reason: None }, + FieldMetadata { name: "stage", field_type: FieldType::Token, required: true, description: "Native failure stage, process-start, initialization-timeout, transport-exit or transport-receive", reason: None }, + ], + span: None, + rate: 60, + owner: "LitheDebugModule", + }, EventMetadata { id: "editor.document.header_failed", channel: "editor", @@ -1226,6 +1327,62 @@ pub static EVENTS: &[EventMetadata] = &[ rate: 120, owner: "lithe-core", }, + EventMetadata { + id: "lsp.message.detail.logged", + channel: "lsp/{server}", + level: LogLevel::Debug, + message: "Language server log message received for server {server} with severity {severity}", + fields: &[ + FieldMetadata { name: "diagnostic", field_type: FieldType::Text, required: true, description: "Original message from the window/logMessage params, redacted and capped at 1024 UTF-8 bytes", reason: Some("The language server owns the unstructured window/logMessage explanation and exposes no stable error code for it, so preserve its original wording after redaction and truncation.") }, + FieldMetadata { name: "server", field_type: FieldType::Id, required: true, description: "Validated language server session id that sent window/logMessage", reason: None }, + FieldMetadata { name: "severity", field_type: FieldType::Token, required: true, description: "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log", reason: None }, + ], + span: None, + rate: 120, + owner: "lithe-core", + }, + EventMetadata { + id: "lsp.message.detail.shown", + channel: "lsp/{server}", + level: LogLevel::Debug, + message: "Language server show message received for server {server} with severity {severity}", + fields: &[ + FieldMetadata { name: "diagnostic", field_type: FieldType::Text, required: true, description: "Original message from the window/showMessage params, redacted and capped at 1024 UTF-8 bytes", reason: Some("The language server owns the window/showMessage explanation shown to the user; retain its original wording after redaction and truncation to correlate a reported message with the server session.") }, + FieldMetadata { name: "server", field_type: FieldType::Id, required: true, description: "Validated language server session id that sent window/showMessage", reason: None }, + FieldMetadata { name: "severity", field_type: FieldType::Token, required: true, description: "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log", reason: None }, + ], + span: None, + rate: 120, + owner: "lithe-core", + }, + EventMetadata { + id: "lsp.message.error.logged", + channel: "lsp/{server}", + level: LogLevel::Error, + message: "Language server log message received for server {server} with severity {severity}", + fields: &[ + FieldMetadata { name: "diagnostic", field_type: FieldType::Text, required: true, description: "Original message from the window/logMessage params, redacted and capped at 1024 UTF-8 bytes", reason: Some("The language server owns the unstructured window/logMessage explanation and exposes no stable error code for it, so preserve its original wording after redaction and truncation.") }, + FieldMetadata { name: "server", field_type: FieldType::Id, required: true, description: "Validated language server session id that sent window/logMessage", reason: None }, + FieldMetadata { name: "severity", field_type: FieldType::Token, required: true, description: "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log", reason: None }, + ], + span: None, + rate: 120, + owner: "lithe-core", + }, + EventMetadata { + id: "lsp.message.error.shown", + channel: "lsp/{server}", + level: LogLevel::Error, + message: "Language server show message received for server {server} with severity {severity}", + fields: &[ + FieldMetadata { name: "diagnostic", field_type: FieldType::Text, required: true, description: "Original message from the window/showMessage params, redacted and capped at 1024 UTF-8 bytes", reason: Some("The language server owns the window/showMessage explanation shown to the user; retain its original wording after redaction and truncation to correlate a reported message with the server session.") }, + FieldMetadata { name: "server", field_type: FieldType::Id, required: true, description: "Validated language server session id that sent window/showMessage", reason: None }, + FieldMetadata { name: "severity", field_type: FieldType::Token, required: true, description: "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log", reason: None }, + ], + span: None, + rate: 120, + owner: "lithe-core", + }, EventMetadata { id: "lsp.message.logged", channel: "lsp/{server}", @@ -1254,6 +1411,34 @@ pub static EVENTS: &[EventMetadata] = &[ rate: 120, owner: "lithe-core", }, + EventMetadata { + id: "lsp.message.warning.logged", + channel: "lsp/{server}", + level: LogLevel::Warn, + message: "Language server log message received for server {server} with severity {severity}", + fields: &[ + FieldMetadata { name: "diagnostic", field_type: FieldType::Text, required: true, description: "Original message from the window/logMessage params, redacted and capped at 1024 UTF-8 bytes", reason: Some("The language server owns the unstructured window/logMessage explanation and exposes no stable error code for it, so preserve its original wording after redaction and truncation.") }, + FieldMetadata { name: "server", field_type: FieldType::Id, required: true, description: "Validated language server session id that sent window/logMessage", reason: None }, + FieldMetadata { name: "severity", field_type: FieldType::Token, required: true, description: "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log", reason: None }, + ], + span: None, + rate: 120, + owner: "lithe-core", + }, + EventMetadata { + id: "lsp.message.warning.shown", + channel: "lsp/{server}", + level: LogLevel::Warn, + message: "Language server show message received for server {server} with severity {severity}", + fields: &[ + FieldMetadata { name: "diagnostic", field_type: FieldType::Text, required: true, description: "Original message from the window/showMessage params, redacted and capped at 1024 UTF-8 bytes", reason: Some("The language server owns the window/showMessage explanation shown to the user; retain its original wording after redaction and truncation to correlate a reported message with the server session.") }, + FieldMetadata { name: "server", field_type: FieldType::Id, required: true, description: "Validated language server session id that sent window/showMessage", reason: None }, + FieldMetadata { name: "severity", field_type: FieldType::Token, required: true, description: "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log", reason: None }, + ], + span: None, + rate: 120, + owner: "lithe-core", + }, EventMetadata { id: "lsp.navigation.cache_hit", channel: "lsp/{server}", @@ -1396,6 +1581,20 @@ pub static EVENTS: &[EventMetadata] = &[ rate: 60, owner: "LanguageToolingSessionManager", }, + EventMetadata { + id: "lsp.session.failed", + channel: "lsp/{server}", + level: LogLevel::Error, + message: "Engine session failed", + fields: &[ + FieldMetadata { name: "code", field_type: FieldType::Token, required: true, description: "Stable Core lifecycle failure category, such as serverExited or transportFailed, without protocol or process contents", reason: None }, + FieldMetadata { name: "server", field_type: FieldType::Id, required: true, description: "Language provider identity from the registered host provider catalog.", reason: None }, + FieldMetadata { name: "state", field_type: FieldType::Token, required: true, description: "Core session lifecycle state emitted by the managed engine (for example ready or stopped).", reason: None }, + ], + span: None, + rate: 60, + owner: "lithe-core", + }, EventMetadata { id: "lsp.session.issue_failed", channel: "lsp/{server}", @@ -1465,10 +1664,10 @@ pub static EVENTS: &[EventMetadata] = &[ EventMetadata { id: "lsp.stderr.received", channel: "lsp/{server}", - level: LogLevel::Warn, + level: LogLevel::Debug, message: "Language server stderr received for server {server}", fields: &[ - FieldMetadata { name: "diagnostic", field_type: FieldType::Text, required: true, description: "Original language server stderr diagnostic, redacted and capped at 1024 UTF-8 bytes", reason: Some("Language servers emit implementation-specific failure explanations only on stderr, so retain the original diagnostic wording for diagnosis after redaction and truncation rather than attempting an unstable parser.") }, + FieldMetadata { name: "diagnostic", field_type: FieldType::Text, required: true, description: "Unclassified upstream stderr diagnostic; retained only for detailed diagnosis and never treated as an operation failure", reason: Some("Language servers emit implementation-specific failure explanations only on stderr, so retain the original diagnostic wording for diagnosis after redaction and truncation rather than attempting an unstable parser.") }, FieldMetadata { name: "server", field_type: FieldType::Id, required: true, description: "Validated language server session id that wrote the stderr diagnostic", reason: None }, FieldMetadata { name: "truncated", field_type: FieldType::Bool, required: false, description: "Whether the upstream stderr line exceeded the 4096 byte reader limit", reason: None }, ], @@ -1714,6 +1913,19 @@ pub static EVENTS: &[EventMetadata] = &[ rate: 60, owner: "Lithe", }, + EventMetadata { + id: "plugins.diagnostics.rejected", + channel: "plugins/{plugin}/host", + level: LogLevel::Warn, + message: "Plugin diagnostic input rejected", + fields: &[ + FieldMetadata { name: "code", field_type: FieldType::Token, required: true, description: "Fixed rejection category invalid_diagnostic; the rejected private payload is never retained", reason: None }, + FieldMetadata { name: "plugin", field_type: FieldType::Id, required: true, description: "Installed plugin identity whose unnegotiated or malformed diagnostic was rejected", reason: None }, + ], + span: None, + rate: 1, + owner: "lithe-core", + }, EventMetadata { id: "plugins.editor.action_failed", channel: "plugins/{plugin}/host", @@ -1781,6 +1993,62 @@ pub static EVENTS: &[EventMetadata] = &[ rate: 60, owner: "Lithe", }, + EventMetadata { + id: "plugins.host.message.detail.logged", + channel: "plugins/{plugin}/host", + level: LogLevel::Debug, + message: "Engine log message received for server {plugin} with severity {severity}", + fields: &[ + FieldMetadata { name: "diagnostic", field_type: FieldType::Text, required: true, description: "Original message from the window/logMessage params, redacted and capped at 1024 UTF-8 bytes", reason: Some("The language server owns the unstructured window/logMessage explanation and exposes no stable error code for it, so preserve its original wording after redaction and truncation.") }, + FieldMetadata { name: "plugin", field_type: FieldType::Id, required: true, description: "Validated plugin identity assigned by Core to this engine session", reason: None }, + FieldMetadata { name: "severity", field_type: FieldType::Token, required: true, description: "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log", reason: None }, + ], + span: None, + rate: 120, + owner: "lithe-core", + }, + EventMetadata { + id: "plugins.host.message.detail.shown", + channel: "plugins/{plugin}/host", + level: LogLevel::Debug, + message: "Engine show message received for server {plugin} with severity {severity}", + fields: &[ + FieldMetadata { name: "diagnostic", field_type: FieldType::Text, required: true, description: "Original message from the window/showMessage params, redacted and capped at 1024 UTF-8 bytes", reason: Some("The language server owns the window/showMessage explanation shown to the user; retain its original wording after redaction and truncation to correlate a reported message with the server session.") }, + FieldMetadata { name: "plugin", field_type: FieldType::Id, required: true, description: "Validated plugin identity assigned by Core to this engine session", reason: None }, + FieldMetadata { name: "severity", field_type: FieldType::Token, required: true, description: "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log", reason: None }, + ], + span: None, + rate: 120, + owner: "lithe-core", + }, + EventMetadata { + id: "plugins.host.message.error.logged", + channel: "plugins/{plugin}/host", + level: LogLevel::Error, + message: "Engine log message received for server {plugin} with severity {severity}", + fields: &[ + FieldMetadata { name: "diagnostic", field_type: FieldType::Text, required: true, description: "Original message from the window/logMessage params, redacted and capped at 1024 UTF-8 bytes", reason: Some("The language server owns the unstructured window/logMessage explanation and exposes no stable error code for it, so preserve its original wording after redaction and truncation.") }, + FieldMetadata { name: "plugin", field_type: FieldType::Id, required: true, description: "Validated plugin identity assigned by Core to this engine session", reason: None }, + FieldMetadata { name: "severity", field_type: FieldType::Token, required: true, description: "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log", reason: None }, + ], + span: None, + rate: 120, + owner: "lithe-core", + }, + EventMetadata { + id: "plugins.host.message.error.shown", + channel: "plugins/{plugin}/host", + level: LogLevel::Error, + message: "Engine show message received for server {plugin} with severity {severity}", + fields: &[ + FieldMetadata { name: "diagnostic", field_type: FieldType::Text, required: true, description: "Original message from the window/showMessage params, redacted and capped at 1024 UTF-8 bytes", reason: Some("The language server owns the window/showMessage explanation shown to the user; retain its original wording after redaction and truncation to correlate a reported message with the server session.") }, + FieldMetadata { name: "plugin", field_type: FieldType::Id, required: true, description: "Validated plugin identity assigned by Core to this engine session", reason: None }, + FieldMetadata { name: "severity", field_type: FieldType::Token, required: true, description: "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log", reason: None }, + ], + span: None, + rate: 120, + owner: "lithe-core", + }, EventMetadata { id: "plugins.host.message.logged", channel: "plugins/{plugin}/host", @@ -1809,6 +2077,34 @@ pub static EVENTS: &[EventMetadata] = &[ rate: 120, owner: "lithe-core", }, + EventMetadata { + id: "plugins.host.message.warning.logged", + channel: "plugins/{plugin}/host", + level: LogLevel::Warn, + message: "Engine log message received for server {plugin} with severity {severity}", + fields: &[ + FieldMetadata { name: "diagnostic", field_type: FieldType::Text, required: true, description: "Original message from the window/logMessage params, redacted and capped at 1024 UTF-8 bytes", reason: Some("The language server owns the unstructured window/logMessage explanation and exposes no stable error code for it, so preserve its original wording after redaction and truncation.") }, + FieldMetadata { name: "plugin", field_type: FieldType::Id, required: true, description: "Validated plugin identity assigned by Core to this engine session", reason: None }, + FieldMetadata { name: "severity", field_type: FieldType::Token, required: true, description: "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log", reason: None }, + ], + span: None, + rate: 120, + owner: "lithe-core", + }, + EventMetadata { + id: "plugins.host.message.warning.shown", + channel: "plugins/{plugin}/host", + level: LogLevel::Warn, + message: "Engine show message received for server {plugin} with severity {severity}", + fields: &[ + FieldMetadata { name: "diagnostic", field_type: FieldType::Text, required: true, description: "Original message from the window/showMessage params, redacted and capped at 1024 UTF-8 bytes", reason: Some("The language server owns the window/showMessage explanation shown to the user; retain its original wording after redaction and truncation to correlate a reported message with the server session.") }, + FieldMetadata { name: "plugin", field_type: FieldType::Id, required: true, description: "Validated plugin identity assigned by Core to this engine session", reason: None }, + FieldMetadata { name: "severity", field_type: FieldType::Token, required: true, description: "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log", reason: None }, + ], + span: None, + rate: 120, + owner: "lithe-core", + }, EventMetadata { id: "plugins.host.notification.received", channel: "plugins/{plugin}/host", @@ -1886,6 +2182,20 @@ pub static EVENTS: &[EventMetadata] = &[ rate: 120, owner: "lithe-core", }, + EventMetadata { + id: "plugins.host.session.failed", + channel: "plugins/{plugin}/host", + level: LogLevel::Error, + message: "Engine session failed", + fields: &[ + FieldMetadata { name: "code", field_type: FieldType::Token, required: true, description: "Stable Core lifecycle failure category, such as serverExited or transportFailed, without protocol or process contents", reason: None }, + FieldMetadata { name: "plugin", field_type: FieldType::Id, required: true, description: "Validated plugin identity assigned by Core to this engine session", reason: None }, + FieldMetadata { name: "state", field_type: FieldType::Token, required: true, description: "Core session lifecycle state emitted by the managed engine (for example ready or stopped).", reason: None }, + ], + span: None, + rate: 60, + owner: "lithe-core", + }, EventMetadata { id: "plugins.host.session.updated", channel: "plugins/{plugin}/host", @@ -1927,10 +2237,10 @@ pub static EVENTS: &[EventMetadata] = &[ EventMetadata { id: "plugins.host.stderr.received", channel: "plugins/{plugin}/host", - level: LogLevel::Warn, + level: LogLevel::Debug, message: "Engine stderr received for server {plugin}", fields: &[ - FieldMetadata { name: "diagnostic", field_type: FieldType::Text, required: true, description: "Original language server stderr diagnostic, redacted and capped at 1024 UTF-8 bytes", reason: Some("Language servers emit implementation-specific failure explanations only on stderr, so retain the original diagnostic wording for diagnosis after redaction and truncation rather than attempting an unstable parser.") }, + FieldMetadata { name: "diagnostic", field_type: FieldType::Text, required: true, description: "Unclassified upstream stderr diagnostic; retained only for detailed diagnosis and never treated as an operation failure", reason: Some("Language servers emit implementation-specific failure explanations only on stderr, so retain the original diagnostic wording for diagnosis after redaction and truncation rather than attempting an unstable parser.") }, FieldMetadata { name: "plugin", field_type: FieldType::Id, required: true, description: "Validated plugin identity assigned by Core to this engine session", reason: None }, FieldMetadata { name: "truncated", field_type: FieldType::Bool, required: false, description: "Whether the upstream stderr line exceeded the 4096 byte reader limit", reason: None }, ], @@ -2016,6 +2326,67 @@ pub static EVENTS: &[EventMetadata] = &[ rate: 60, owner: "Lithe", }, + EventMetadata { + id: "plugins.operation.failed", + channel: "plugins/{plugin}/host", + level: LogLevel::Error, + message: "Plugin operation failed", + fields: &[ + FieldMetadata { name: "code", field_type: FieldType::Token, required: true, description: "Stable plugin failure or recovery category, excluding upstream error messages and request contents", reason: None }, + FieldMetadata { name: "count", field_type: FieldType::Int, required: false, description: "Number of affected items reported by the plugin at this result boundary; absent when unknown", reason: None }, + FieldMetadata { name: "duration", field_type: FieldType::Duration, required: true, description: "Monotonic elapsed milliseconds measured by the SDK for the completed plugin operation", reason: None }, + FieldMetadata { name: "operation", field_type: FieldType::Token, required: true, description: "Registered plugin function or SDK protocol operation that owns this diagnostic result", reason: None }, + FieldMetadata { name: "plugin", field_type: FieldType::Id, required: true, description: "Installed plugin identity supplied by the owning Core session, never by the notification", reason: None }, + ], + span: None, + rate: 600, + owner: "lithe-core", + }, + EventMetadata { + id: "plugins.operation.fallback_selected", + channel: "plugins/{plugin}/host", + level: LogLevel::Warn, + message: "Plugin operation used a degraded result", + fields: &[ + FieldMetadata { name: "code", field_type: FieldType::Token, required: true, description: "Stable plugin failure or recovery category, excluding upstream error messages and request contents", reason: None }, + FieldMetadata { name: "count", field_type: FieldType::Int, required: false, description: "Number of affected items reported by the plugin at this result boundary; absent when unknown", reason: None }, + FieldMetadata { name: "operation", field_type: FieldType::Token, required: true, description: "Registered plugin function or SDK protocol operation that owns this diagnostic result", reason: None }, + FieldMetadata { name: "plugin", field_type: FieldType::Id, required: true, description: "Installed plugin identity supplied by the owning Core session, never by the notification", reason: None }, + ], + span: None, + rate: 600, + owner: "lithe-core", + }, + EventMetadata { + id: "plugins.operation.finished", + channel: "plugins/{plugin}/host", + level: LogLevel::Info, + message: "Plugin operation finished", + fields: &[ + FieldMetadata { name: "count", field_type: FieldType::Int, required: false, description: "Number of affected items reported by the plugin at this result boundary; absent when unknown", reason: None }, + FieldMetadata { name: "duration", field_type: FieldType::Duration, required: true, description: "Monotonic elapsed milliseconds measured by the SDK for the completed plugin operation", reason: None }, + FieldMetadata { name: "operation", field_type: FieldType::Token, required: true, description: "Registered plugin function or SDK protocol operation that owns this diagnostic result", reason: None }, + FieldMetadata { name: "plugin", field_type: FieldType::Id, required: true, description: "Installed plugin identity supplied by the owning Core session, never by the notification", reason: None }, + ], + span: None, + rate: 600, + owner: "lithe-core", + }, + EventMetadata { + id: "plugins.operation.recovery_finished", + channel: "plugins/{plugin}/host", + level: LogLevel::Warn, + message: "Plugin operation recovered", + fields: &[ + FieldMetadata { name: "code", field_type: FieldType::Token, required: true, description: "Stable plugin failure or recovery category, excluding upstream error messages and request contents", reason: None }, + FieldMetadata { name: "count", field_type: FieldType::Int, required: false, description: "Number of affected items reported by the plugin at this result boundary; absent when unknown", reason: None }, + FieldMetadata { name: "operation", field_type: FieldType::Token, required: true, description: "Registered plugin function or SDK protocol operation that owns this diagnostic result", reason: None }, + FieldMetadata { name: "plugin", field_type: FieldType::Id, required: true, description: "Installed plugin identity supplied by the owning Core session, never by the notification", reason: None }, + ], + span: None, + rate: 600, + owner: "lithe-core", + }, EventMetadata { id: "plugins.resources.maintenance_failed", channel: "plugins", @@ -2611,6 +2982,62 @@ pub static EVENTS: &[EventMetadata] = &[ rate: 60, owner: "lithe-core", }, + EventMetadata { + id: "vcs.message.detail.logged", + channel: "vcs/{provider}", + level: LogLevel::Debug, + message: "Engine log message received for server {provider} with severity {severity}", + fields: &[ + FieldMetadata { name: "diagnostic", field_type: FieldType::Text, required: true, description: "Original message from the window/logMessage params, redacted and capped at 1024 UTF-8 bytes", reason: Some("The language server owns the unstructured window/logMessage explanation and exposes no stable error code for it, so preserve its original wording after redaction and truncation.") }, + FieldMetadata { name: "provider", field_type: FieldType::Id, required: true, description: "Validated provider identity assigned by Core to this engine session", reason: None }, + FieldMetadata { name: "severity", field_type: FieldType::Token, required: true, description: "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log", reason: None }, + ], + span: None, + rate: 120, + owner: "lithe-core", + }, + EventMetadata { + id: "vcs.message.detail.shown", + channel: "vcs/{provider}", + level: LogLevel::Debug, + message: "Engine show message received for server {provider} with severity {severity}", + fields: &[ + FieldMetadata { name: "diagnostic", field_type: FieldType::Text, required: true, description: "Original message from the window/showMessage params, redacted and capped at 1024 UTF-8 bytes", reason: Some("The language server owns the window/showMessage explanation shown to the user; retain its original wording after redaction and truncation to correlate a reported message with the server session.") }, + FieldMetadata { name: "provider", field_type: FieldType::Id, required: true, description: "Validated provider identity assigned by Core to this engine session", reason: None }, + FieldMetadata { name: "severity", field_type: FieldType::Token, required: true, description: "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log", reason: None }, + ], + span: None, + rate: 120, + owner: "lithe-core", + }, + EventMetadata { + id: "vcs.message.error.logged", + channel: "vcs/{provider}", + level: LogLevel::Error, + message: "Engine log message received for server {provider} with severity {severity}", + fields: &[ + FieldMetadata { name: "diagnostic", field_type: FieldType::Text, required: true, description: "Original message from the window/logMessage params, redacted and capped at 1024 UTF-8 bytes", reason: Some("The language server owns the unstructured window/logMessage explanation and exposes no stable error code for it, so preserve its original wording after redaction and truncation.") }, + FieldMetadata { name: "provider", field_type: FieldType::Id, required: true, description: "Validated provider identity assigned by Core to this engine session", reason: None }, + FieldMetadata { name: "severity", field_type: FieldType::Token, required: true, description: "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log", reason: None }, + ], + span: None, + rate: 120, + owner: "lithe-core", + }, + EventMetadata { + id: "vcs.message.error.shown", + channel: "vcs/{provider}", + level: LogLevel::Error, + message: "Engine show message received for server {provider} with severity {severity}", + fields: &[ + FieldMetadata { name: "diagnostic", field_type: FieldType::Text, required: true, description: "Original message from the window/showMessage params, redacted and capped at 1024 UTF-8 bytes", reason: Some("The language server owns the window/showMessage explanation shown to the user; retain its original wording after redaction and truncation to correlate a reported message with the server session.") }, + FieldMetadata { name: "provider", field_type: FieldType::Id, required: true, description: "Validated provider identity assigned by Core to this engine session", reason: None }, + FieldMetadata { name: "severity", field_type: FieldType::Token, required: true, description: "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log", reason: None }, + ], + span: None, + rate: 120, + owner: "lithe-core", + }, EventMetadata { id: "vcs.message.logged", channel: "vcs/{provider}", @@ -2639,6 +3066,34 @@ pub static EVENTS: &[EventMetadata] = &[ rate: 120, owner: "lithe-core", }, + EventMetadata { + id: "vcs.message.warning.logged", + channel: "vcs/{provider}", + level: LogLevel::Warn, + message: "Engine log message received for server {provider} with severity {severity}", + fields: &[ + FieldMetadata { name: "diagnostic", field_type: FieldType::Text, required: true, description: "Original message from the window/logMessage params, redacted and capped at 1024 UTF-8 bytes", reason: Some("The language server owns the unstructured window/logMessage explanation and exposes no stable error code for it, so preserve its original wording after redaction and truncation.") }, + FieldMetadata { name: "provider", field_type: FieldType::Id, required: true, description: "Validated provider identity assigned by Core to this engine session", reason: None }, + FieldMetadata { name: "severity", field_type: FieldType::Token, required: true, description: "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log", reason: None }, + ], + span: None, + rate: 120, + owner: "lithe-core", + }, + EventMetadata { + id: "vcs.message.warning.shown", + channel: "vcs/{provider}", + level: LogLevel::Warn, + message: "Engine show message received for server {provider} with severity {severity}", + fields: &[ + FieldMetadata { name: "diagnostic", field_type: FieldType::Text, required: true, description: "Original message from the window/showMessage params, redacted and capped at 1024 UTF-8 bytes", reason: Some("The language server owns the window/showMessage explanation shown to the user; retain its original wording after redaction and truncation to correlate a reported message with the server session.") }, + FieldMetadata { name: "provider", field_type: FieldType::Id, required: true, description: "Validated provider identity assigned by Core to this engine session", reason: None }, + FieldMetadata { name: "severity", field_type: FieldType::Token, required: true, description: "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log", reason: None }, + ], + span: None, + rate: 120, + owner: "lithe-core", + }, EventMetadata { id: "vcs.notification.received", channel: "vcs/{provider}", @@ -2716,6 +3171,20 @@ pub static EVENTS: &[EventMetadata] = &[ rate: 60, owner: "Lithe", }, + EventMetadata { + id: "vcs.session.failed", + channel: "vcs/{provider}", + level: LogLevel::Error, + message: "Engine session failed", + fields: &[ + FieldMetadata { name: "code", field_type: FieldType::Token, required: true, description: "Stable Core lifecycle failure category, such as serverExited or transportFailed, without protocol or process contents", reason: None }, + FieldMetadata { name: "provider", field_type: FieldType::Id, required: true, description: "Validated provider identity assigned by Core to this engine session", reason: None }, + FieldMetadata { name: "state", field_type: FieldType::Token, required: true, description: "Core session lifecycle state emitted by the managed engine (for example ready or stopped).", reason: None }, + ], + span: None, + rate: 60, + owner: "lithe-core", + }, EventMetadata { id: "vcs.session.updated", channel: "vcs/{provider}", @@ -2770,10 +3239,10 @@ pub static EVENTS: &[EventMetadata] = &[ EventMetadata { id: "vcs.stderr.received", channel: "vcs/{provider}", - level: LogLevel::Warn, + level: LogLevel::Debug, message: "Engine stderr received for server {provider}", fields: &[ - FieldMetadata { name: "diagnostic", field_type: FieldType::Text, required: true, description: "Original language server stderr diagnostic, redacted and capped at 1024 UTF-8 bytes", reason: Some("Language servers emit implementation-specific failure explanations only on stderr, so retain the original diagnostic wording for diagnosis after redaction and truncation rather than attempting an unstable parser.") }, + FieldMetadata { name: "diagnostic", field_type: FieldType::Text, required: true, description: "Unclassified upstream stderr diagnostic; retained only for detailed diagnosis and never treated as an operation failure", reason: Some("Language servers emit implementation-specific failure explanations only on stderr, so retain the original diagnostic wording for diagnosis after redaction and truncation rather than attempting an unstable parser.") }, FieldMetadata { name: "provider", field_type: FieldType::Id, required: true, description: "Validated provider identity assigned by Core to this engine session", reason: None }, FieldMetadata { name: "truncated", field_type: FieldType::Bool, required: false, description: "Whether the upstream stderr line exceeded the 4096 byte reader limit", reason: None }, ], diff --git a/rust/lithe-core/src/diagnostics/core_events.rs b/rust/lithe-core/src/diagnostics/core_events.rs index 99f6423187..c8661aaecf 100644 --- a/rust/lithe-core/src/diagnostics/core_events.rs +++ b/rust/lithe-core/src/diagnostics/core_events.rs @@ -1,4 +1,6 @@ -//! Bounded process-owned handoff of catalog facts to the host's single log writer. +//! Bounded process-owned handoff of catalog facts to the host's single log writer, +//! with a coalesced wake so facts recorded without other host activity still +//! reach the writer without polling. use super::catalog::event_by_id; use super::channel::LogError; @@ -6,10 +8,27 @@ use super::record::{prepare_catalog_event, LogLevel, StructuredLogEvent}; use serde::Serialize; use serde_json::{json, Value}; use std::collections::{BTreeMap, VecDeque}; -use std::sync::{Mutex, OnceLock}; +use std::ffi::c_void; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, MutexGuard, OnceLock}; const MAX_EVENTS: usize = 256; static QUEUE: OnceLock> = OnceLock::new(); +// One process writer may own the pending-fact signal; a later install replaces it. +static WAKER: Mutex> = Mutex::new(None); +// Set by the first change after a drain and cleared by the next drain, so a +// burst of facts produces one host wakeup instead of one callback per fact. +static WAKE_REQUESTED: AtomicBool = AtomicBool::new(false); + +/// Host callback that schedules the existing single writer to drain this queue. +/// +/// `context` is an opaque host address. Core never dereferences it; it is only +/// passed back to `callback` while the installation lock is held. +#[derive(Clone, Copy)] +struct Waker { + callback: unsafe extern "C" fn(*mut c_void), + context: usize, +} /// Normalized fact ready for a host to assign its own session sequence. #[derive(Debug, Clone, Serialize)] @@ -45,6 +64,10 @@ struct CoreEventQueue { } impl CoreEventQueue { + fn is_empty(&self) -> bool { + self.events.is_empty() && self.dropped == 0 + } + fn record(&mut self, event: CoreEvent) { // Repeated inaccessible-directory facts cannot fill the queue. The scan // is bounded by MAX_EVENTS and needs no second, independently growing set. @@ -78,12 +101,62 @@ pub fn record( op: Option, ) -> Result<(), LogError> { let fact = prepare(event, fields, op)?; + queue().record(fact); + wake(); + Ok(()) +} + +fn queue() -> MutexGuard<'static, CoreEventQueue> { QUEUE .get_or_init(Mutex::default) .lock() .unwrap_or_else(|e| e.into_inner()) - .record(fact); - Ok(()) +} + +/// Signals the installed writer once per drain cycle. Must be called without +/// the queue lock: the host callback may take its own writer lock. +fn wake() { + if WAKE_REQUESTED.swap(true, Ordering::AcqRel) { + return; + } + // Holding WAKER across the call serializes every callback with removal, so + // after `remove_waker` returns the host may release `context`. + let waker = WAKER.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(waker) = *waker { + // SAFETY: `install_waker`'s caller guarantees the callback is safe to + // invoke from any thread with `context` until `remove_waker` returns. + unsafe { (waker.callback)(waker.context as *mut c_void) } + } +} + +/// Installs the process writer's pending-fact callback, replacing any previous +/// writer. Facts already waiting are signaled immediately so a writer attached +/// after early Core work does not wait for unrelated activity. +/// +/// # Safety +/// +/// `callback` must not unwind, must not call back into Core synchronously, and +/// must be safe to run on any thread. `context` must stay valid until +/// [`remove_waker`] returns for the same context or another install replaces it. +pub(crate) unsafe fn install_waker(callback: unsafe extern "C" fn(*mut c_void), context: usize) { + *WAKER.lock().unwrap_or_else(|e| e.into_inner()) = Some(Waker { callback, context }); + // A request raised while no writer was installed was never delivered. + WAKE_REQUESTED.store(false, Ordering::Release); + if !queue().is_empty() { + wake(); + } +} + +/// Removes the callback only when `context` still owns it. Returns whether it +/// was removed; either way no callback for `context` is running afterward. +pub(crate) fn remove_waker(context: usize) -> bool { + let mut waker = WAKER.lock().unwrap_or_else(|e| e.into_inner()); + if waker.is_some_and(|current| current.context == context) { + *waker = None; + true + } else { + false + } } pub(super) fn prepare( @@ -118,11 +191,11 @@ pub(super) fn prepare( /// Drains all pending facts and resets the overflow counter in one short lock. pub fn drain() -> DrainedCoreEvents { - QUEUE - .get_or_init(Mutex::default) - .lock() - .unwrap_or_else(|e| e.into_inner()) - .drain() + let mut queue = queue(); + // Cleared under the queue lock: any fact recorded after this snapshot + // raises a new wakeup, so no fact can wait behind a stale request. + WAKE_REQUESTED.store(false, Ordering::Release); + queue.drain() } /// Records a known category-only cleanup fact; invalid declarations count as lost @@ -166,12 +239,13 @@ pub(crate) fn directory_skipped(event: &str, path: &str, code: &crate::protocol: } } -pub(super) fn note_dropped() { - let mut queue = QUEUE - .get_or_init(Mutex::default) - .lock() - .unwrap_or_else(|e| e.into_inner()); - queue.dropped = queue.dropped.saturating_add(1); +pub(crate) fn note_dropped() { + { + let mut queue = queue(); + queue.dropped = queue.dropped.saturating_add(1); + } + // The drop summary is evidence too; it must reach disk without other activity. + wake(); } /// Hands a category-only recovery failure to the host; callers own the decision diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index d15360a060..c2d65070ad 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -612,6 +612,15 @@ pub(super) fn client_apply_server_message_for_plugin( state.server_capabilities = feature_names_from_capabilities( result.get("capabilities").unwrap_or(&Value::Null), ); + if plugin_owned + && super::plugin_logging::negotiated( + result.get("capabilities").unwrap_or(&Value::Null), + ) + { + state + .server_capabilities + .push(super::plugin_logging::FEATURE.into()); + } let functions = plugin_owned .then(|| { plugin_function::negotiate( @@ -853,6 +862,7 @@ fn experimental_capabilities( ) -> Value { let mut experimental = super::build_provider::client_experimental_capabilities(); if plugin_owned { + experimental[super::plugin_logging::FEATURE] = json!({"versions": [1]}); let (functions_key, functions_offer) = plugin_function::client_experimental_capabilities(); experimental[functions_key] = functions_offer; let (vcs_key, vcs_offer) = super::vcs_provider::client_experimental_capabilities(); diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index 8dcea17914..c5bd14bf10 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -76,6 +76,8 @@ const MAX_TOOLCHAIN_QUERY_TIMEOUT_MS: u64 = 60_000; /// Bounds one provider request; the host can cancel sooner when a snapshot expires. const MAX_KEYMAP_IMPORT_TIMEOUT_MS: u64 = 60_000; const MONITOR_INTERVAL_MS: u64 = 10; +/// Shared plugin diagnostic budget per process session and monotonic minute. +const MAX_PLUGIN_DIAGNOSTICS_PER_MINUTE: u16 = 600; /// JSON-RPC method carrying Java Debug Server build commands. const JAVA_BUILD_METHOD: &str = "workspace/executeCommand"; @@ -884,6 +886,11 @@ struct SessionState { detailed_diagnostics: bool, /// Desired trace setting retained until the initialize handshake permits notifications. requested_diagnostics: bool, + /// Malformed plugin diagnostics are summarized once per process session. + rejected_plugin_diagnostics: bool, + /// Sender-side ingress budget uses the existing process clock, never a timer. + plugin_diagnostics_window: Instant, + plugin_diagnostics_count: u16, } struct RuntimeSession { @@ -2149,6 +2156,9 @@ impl LspEngine { next_host_request_id: 1, detailed_diagnostics: false, requested_diagnostics: false, + rejected_plugin_diagnostics: false, + plugin_diagnostics_window: Instant::now(), + plugin_diagnostics_count: 0, }), event_signal: Condvar::new(), process: process.handle, @@ -3842,10 +3852,8 @@ impl RuntimeSession { StderrFact::Line { diagnostic, truncated, - } => error_session.log_issue( - "warning", - "stderr", - "upstreamStderr", + } => error_session.log( + "debug", EngineLog::StderrReceived .with_fields(json!({"diagnostic": diagnostic, "truncated": truncated})), "Language-server stderr", @@ -4911,6 +4919,10 @@ impl RuntimeSession { event.diagnostics.unwrap_or_default(), ); } + } else if event.kind == "notification" + && event.method.as_deref() == Some(super::plugin_logging::METHOD) + { + receive_plugin_diagnostic(self, &mut state, event.result.as_ref()); } else if event.kind == "notification" && self.plugin_id.is_some() && event.method.as_deref() == Some("$/cancelRequest") @@ -6756,8 +6768,11 @@ fn transition_locked( error: Option, ) { state.lifecycle = lifecycle; - let (id, fields) = - EngineLog::SessionUpdated.fact(&session.log_identity, &json!({"state": lifecycle}), None); + let (id, fields) = EngineLog::SessionUpdated.fact( + &session.log_identity, + &json!({"state": lifecycle}), + error.as_ref(), + ); let sequence = take_sequence(state); enqueue_runtime_event( session, @@ -7645,6 +7660,76 @@ fn push_server_info_event(session: &RuntimeSession, state: &mut SessionState, in ); } +/// Accepts only negotiated scalar plugin facts. Persistence and overload accounting +/// reuse the bounded Core handoff instead of copying protocol params into v1 detail. +fn receive_plugin_diagnostic( + session: &RuntimeSession, + state: &mut SessionState, + params: Option<&Value>, +) { + let Some(plugin) = session.plugin_id.as_deref() else { + return; + }; + let plugin_identity = SessionLogIdentity::new( + SessionRole::Backend, + &session.provider_id, + Some(plugin), + None, + ); + let plugin = plugin_identity.segment(); + let valid = state + .client + .server_capabilities + .iter() + .any(|name| name == super::plugin_logging::FEATURE) + .then(|| { + super::plugin_logging::decode( + plugin, + &state.client.plugin_functions, + params.unwrap_or(&Value::Null), + ) + }) + .flatten(); + let Some(fact) = valid else { + if !state.rejected_plugin_diagnostics { + state.rejected_plugin_diagnostics = true; + if crate::diagnostics::core_events::record( + "plugins.diagnostics.rejected", + BTreeMap::from([ + ("plugin".into(), json!(plugin)), + ("code".into(), json!("invalid_diagnostic")), + ]), + None, + ) + .is_err() + { + crate::diagnostics::core_events::note_dropped(); + } + } + return; + }; + if state.plugin_diagnostics_window.elapsed() >= Duration::from_secs(60) { + state.plugin_diagnostics_window = Instant::now(); + state.plugin_diagnostics_count = 0; + } + if state.plugin_diagnostics_count >= MAX_PLUGIN_DIAGNOSTICS_PER_MINUTE { + crate::diagnostics::core_events::note_dropped(); + return; + } + state.plugin_diagnostics_count += 1; + // SDK contexts carry wire request IDs, not reusable UI operation IDs. Only + // a live request owned by this session may extend the host's correlation. + let operation = fact.wire_operation.as_deref().and_then(|wire| { + state + .pending + .get(wire) + .and_then(|pending| pending.operation_id.clone()) + }); + if crate::diagnostics::core_events::record(fact.event, fact.fields, operation).is_err() { + crate::diagnostics::core_events::note_dropped(); + } +} + fn push_log_event( session: &RuntimeSession, state: &mut SessionState, @@ -9615,8 +9700,8 @@ mod tests { assert_eq!(dropped.channel.as_deref(), Some("vcs/git")); assert_eq!(dropped.fields.unwrap()["count"], 7); for (method, expected) in [ - ("window/logMessage", "vcs.message.logged"), - ("window/showMessage", "vcs.message.shown"), + ("window/logMessage", "vcs.message.warning.logged"), + ("window/showMessage", "vcs.message.warning.shown"), ] { session.handle_server_message(json!({"jsonrpc":"2.0", "method":method, "params":{"type":2,"message":"warning token=private-value"}}).to_string()).unwrap(); let events = session.poll_events().unwrap(); @@ -9629,7 +9714,7 @@ mod tests { assert_eq!(event.role, Some(SessionRole::Vcs)); assert_eq!( event.catalog_level, - Some(crate::diagnostics::record::LogLevel::Info) + Some(crate::diagnostics::record::LogLevel::Warn) ); assert_eq!(event.message.as_deref(), Some(method)); assert!(event.level.is_some() && event.detail.is_some()); @@ -15598,6 +15683,7 @@ public class Main { json!({ "litheBuild": { "versions": [6, 5, 4, 3, 2, 1] }, "litheFunctions": { "versions": [1, 2, 3] }, + "litheLogging": { "versions": [1] }, "litheVcs": { "versions": [1] } }) ); diff --git a/rust/lithe-core/src/lsp/interface/engine_logging.rs b/rust/lithe-core/src/lsp/interface/engine_logging.rs index 640997a0a5..971d920af7 100644 --- a/rust/lithe-core/src/lsp/interface/engine_logging.rs +++ b/rust/lithe-core/src/lsp/interface/engine_logging.rs @@ -77,6 +77,11 @@ impl SessionLogIdentity { } } + /// Safe catalog identity shared by typed plugin facts and process lifecycle facts. + pub(super) fn segment(&self) -> &str { + &self.subject + } + fn event(&self, suffix: &str) -> String { let prefix = match self.role { SessionRole::LanguageServer => "lsp", @@ -273,6 +278,20 @@ impl EngineLog { Self::MessageLogged | Self::MessageShown => { copy(&mut fields, "diagnostic", &input, "diagnostic"); copy(&mut fields, "severity", &input, "severity"); + let severity = match input["severity"].as_str() { + Some("error") => Some("error"), + Some("warning") => Some("warning"), + Some("info") => None, + _ => Some("detail"), + }; + if let Some(severity) = severity { + let outcome = if matches!(self, Self::MessageLogged) { + "logged" + } else { + "shown" + }; + id = identity.event(&format!("message.{severity}.{outcome}")); + } } Self::TraceReceived => { copy(&mut fields, "diagnostic", &input, "diagnostic"); @@ -282,6 +301,10 @@ impl EngineLog { } Self::SessionUpdated | Self::MavenTaskUpdated => { copy(&mut fields, "state", &input, "state"); + if matches!(self, Self::SessionUpdated) && input["state"] == "failed" { + id = identity.event("session.failed"); + fields.insert("code".into(), json!(code)); + } } Self::ConfigurationCleaned => { fields.insert( @@ -577,6 +600,48 @@ impl StderrLines { mod tests { use super::*; + #[test] + fn upstream_severity_follows_explicit_type_and_unclassified_stderr_is_detail() { + let identity = SessionLogIdentity::new(SessionRole::LanguageServer, "go", None, None); + for method in ["window/logMessage", "window/showMessage"] { + for (kind, level) in [ + (1, "ERROR"), + (2, "WARN"), + (3, "INFO"), + (4, "DEBUG"), + (99, "DEBUG"), + ] { + let (event, input) = EngineLog::notification( + method, + &json!({"type":kind,"message":"upstream fact"}), + ); + let (id, fields) = event.fact(&identity, &input, None); + crate::diagnostics::catalog::validate_fields(&id, &fields).unwrap(); + assert_eq!( + crate::diagnostics::catalog::event_by_id(&id) + .unwrap() + .level + .label(), + level + ); + } + } + assert_eq!( + crate::diagnostics::catalog::event_by_id("lsp.stderr.received") + .unwrap() + .level + .label(), + "DEBUG" + ); + let plugin = SessionLogIdentity::new( + SessionRole::Backend, + "provider", + Some("unsafe:plugin"), + None, + ); + assert!(plugin.segment().starts_with("invalid-")); + } + #[test] fn diagnostics_resolve_engine_subject_matches_startup_and_rejects_invalid_arguments() { for (role, provider, plugin, subject) in [ diff --git a/rust/lithe-core/src/lsp/interface/mod.rs b/rust/lithe-core/src/lsp/interface/mod.rs index 8d5b5f0e64..4d46c9077d 100644 --- a/rust/lithe-core/src/lsp/interface/mod.rs +++ b/rust/lithe-core/src/lsp/interface/mod.rs @@ -6,6 +6,7 @@ mod engine; mod engine_logging; mod keymap_provider; pub(crate) mod plugin_function; +mod plugin_logging; pub(crate) mod process; pub(crate) mod remote_process; #[cfg(test)] diff --git a/rust/lithe-core/src/lsp/interface/plugin_logging.rs b/rust/lithe-core/src/lsp/interface/plugin_logging.rs new file mode 100644 index 0000000000..aa10a5947d --- /dev/null +++ b/rust/lithe-core/src/lsp/interface/plugin_logging.rs @@ -0,0 +1,216 @@ +//! Validates plugin diagnostic facts before handing them to the host's single writer. + +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +pub(super) const FEATURE: &str = "litheLogging"; +pub(super) const METHOD: &str = "lithe/log"; +const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; + +/// Closed facts select catalog severity; a plugin cannot choose a disk event or channel. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +enum Kind { + OperationFinished, + OperationFailed, + Degraded, + Recovered, +} + +/// Only scalar diagnostic metadata crosses this ingress; arbitrary messages are forbidden. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Input { + kind: Kind, + operation: String, + duration_ms: Option, + code: Option, + count: Option, + operation_id: Option, +} + +pub(super) fn negotiated(capabilities: &Value) -> bool { + capabilities.pointer("/experimental/litheLogging/version") == Some(&json!(1)) +} + +fn token(value: &str, limit: usize) -> bool { + !value.is_empty() + && value.len() <= limit + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"._:/-$".contains(&b)) +} + +fn known_operation(operation: &str, functions: &BTreeSet) -> bool { + functions.contains(operation) + || matches!( + operation, + "initialize" | "shutdown" | "notification" | "function-call" | "result-encoding" + ) + || operation == super::keymap_provider::IMPORT_METHOD + || super::build_provider::is_build_method(operation) + || crate::vcs::is_vcs_method(operation) +} + +/// A catalog fact with no plugin-selected identity, message, severity, or raw error text. +pub(super) struct Fact { + pub(super) event: &'static str, + pub(super) fields: BTreeMap, + /// Wire correlation is resolved to the owning host operation by the runtime. + pub(super) wire_operation: Option, +} + +/// Rejects unknown fields and unregistered operations without echoing private input. +pub(super) fn decode(plugin: &str, functions: &BTreeSet, params: &Value) -> Option { + let input = Input::deserialize(params).ok()?; + if !token(&input.operation, 64) + || !known_operation(&input.operation, functions) + || input.code.as_deref().is_some_and(|code| !token(code, 64)) + || input + .operation_id + .as_deref() + .is_some_and(|op| !token(op, 128)) + || input + .duration_ms + .is_some_and(|value| value > MAX_SAFE_INTEGER) + || input.count.is_some_and(|value| value > MAX_SAFE_INTEGER) + { + return None; + } + let (event, needs_duration, needs_code) = match input.kind { + Kind::OperationFinished => ("plugins.operation.finished", true, false), + Kind::OperationFailed => ("plugins.operation.failed", true, true), + Kind::Degraded => ("plugins.operation.fallback_selected", false, true), + Kind::Recovered => ("plugins.operation.recovery_finished", false, true), + }; + if needs_duration != input.duration_ms.is_some() || needs_code != input.code.is_some() { + return None; + } + let mut fields = BTreeMap::from([ + ("plugin".into(), json!(plugin)), + ("operation".into(), json!(input.operation)), + ]); + if let Some(duration) = input.duration_ms { + fields.insert("duration_ms".into(), json!(duration)); + } + if let Some(code) = input.code { + fields.insert("code".into(), json!(code)); + } + if let Some(count) = input.count { + fields.insert("count".into(), json!(count)); + } + Some(Fact { + event, + fields, + wire_operation: input.operation_id, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shared_plugin_logging_fixture_matches_ingress_and_catalog() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../../shared/contracts/plugins/v1/fixtures/plugin-logging.json" + )) + .unwrap(); + let functions = fixture["registeredFunctions"] + .as_array() + .unwrap() + .iter() + .map(|value| value.as_str().unwrap().to_owned()) + .collect(); + for (index, input) in fixture["notifications"] + .as_array() + .unwrap() + .iter() + .enumerate() + { + let fact = decode(fixture["plugin"].as_str().unwrap(), &functions, input).unwrap(); + assert_eq!(fact.event, fixture["events"][index]); + let mut record = crate::diagnostics::record::StructuredLogEvent { + ts: "2026-11-04T00:00:00.000Z".into(), + seq: 0, + level: crate::diagnostics::catalog::event_by_id(fact.event) + .unwrap() + .level, + event: fact.event.into(), + op: None, + msg: String::new(), + fields: fact.fields, + }; + let channel = + crate::diagnostics::record::prepare_catalog_event(&mut record, &Value::Null) + .unwrap(); + assert_eq!(channel, fixture["channel"]); + assert_eq!( + serde_json::to_value(record.level).unwrap(), + fixture["levels"][index] + ); + } + for input in fixture["rejected"].as_array().unwrap() { + assert!(decode(fixture["plugin"].as_str().unwrap(), &functions, input).is_none()); + } + } + + #[test] + fn plugin_facts_choose_catalog_severity_and_reject_private_payloads() { + let functions = BTreeSet::from(["database.call".into()]); + for (kind, expected, level) in [ + ("operationFinished", "plugins.operation.finished", "INFO"), + ("operationFailed", "plugins.operation.failed", "ERROR"), + ("degraded", "plugins.operation.fallback_selected", "WARN"), + ("recovered", "plugins.operation.recovery_finished", "WARN"), + ] { + let mut input = + json!({"kind":kind,"operation":"database.call", "operationId":"request-7"}); + if kind.starts_with("operation") { + input["durationMs"] = json!(15); + } + if kind != "operationFinished" { + input["code"] = json!("database.unavailable"); + } + let fact = decode("io.example.database", &functions, &input).unwrap(); + assert_eq!(fact.event, expected); + crate::diagnostics::catalog::validate_fields(fact.event, &fact.fields).unwrap(); + let metadata = crate::diagnostics::catalog::event_by_id(expected).unwrap(); + assert_eq!(serde_json::to_value(metadata.level).unwrap(), level); + assert_eq!(fact.wire_operation.as_deref(), Some("request-7")); + for (field, value) in [ + ("message", json!("SELECT private_source")), + ("level", json!("INFO")), + ("channel", json!("app")), + ("durationMs", json!(-1)), + ("code", json!("secret value")), + ("operation", json!("unregistered.query")), + ("count", json!(9_007_199_254_740_992_u64)), + ] { + let mut invalid = input.clone(); + invalid[field] = value; + assert!( + decode("io.example.database", &functions, &invalid).is_none(), + "{field}" + ); + } + } + // Protocol requests are accepted by their wire method without a function registration. + for operation in [ + "lithe/build/model", + "lithe/vcs/status", + "lithe/keymap/import", + ] { + let input = json!({"kind":"operationFinished","operation":operation,"durationMs":3}); + let fact = decode("io.example.database", &BTreeSet::new(), &input).unwrap(); + assert_eq!(fact.fields["operation"], operation); + } + assert!(negotiated( + &json!({"experimental":{"litheLogging":{"version":1}}}) + )); + assert!(!negotiated( + &json!({"experimental":{"litheLogging":{"version":2}}}) + )); + } +} diff --git a/rust/lithe-core/src/lsp/languages/jdt.rs b/rust/lithe-core/src/lsp/languages/jdt.rs index ed83f0b53b..ac6f4a4ba7 100644 --- a/rust/lithe-core/src/lsp/languages/jdt.rs +++ b/rust/lithe-core/src/lsp/languages/jdt.rs @@ -844,7 +844,7 @@ const DEFAULT_MAX_HEAP_ARGUMENT: &str = JDTLS_MAX_HEAP_ARGUMENT; const CONSTRAINED_HEAP_MEBIBYTES: u64 = 1536; const MAX_HEAP_PREFIX: &str = "-Xmx"; /// Language-server log level used when the plugin does not choose one. -const DEFAULT_LOG_LEVEL_ARGUMENT: &str = "-Dlog.level=ALL"; +const DEFAULT_LOG_LEVEL_ARGUMENT: &str = "-Dlog.level=WARNING"; const LOG_LEVEL_PREFIX: &str = "-Dlog.level="; /// JVM defaults vscode-java and the JDT LS CI launch the language server with. /// @@ -1934,6 +1934,11 @@ mod tests { "--jvm-arg=-Xmx4g".to_string(), "--jvm-arg=-Dlog.level=INFO".to_string(), "--jvm-arg=-Duser.language=en".to_string(), + "--jvm-arg=-Dosgi.logfile=/session/plugins/java/files/jdt.log".to_string(), + "--jvm-arg=-Declipse.log.include.commandline=false".to_string(), + "--jvm-arg=-Declipse.log.size.max=1024".to_string(), + "--jvm-arg=-Declipse.log.backup.max=2".to_string(), + "--jvm-arg=-Declipse.log.level=WARNING".to_string(), "--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=true".to_string(), "-data=/old/data".to_string(), ]; @@ -1966,6 +1971,11 @@ mod tests { "-Dlog.level=INFO", "-Djava.import.generatesMetadataFilesAtProjectRoot=false", "-Duser.language=en", + "-Dosgi.logfile=/session/plugins/java/files/jdt.log", + "-Declipse.log.include.commandline=false", + "-Declipse.log.size.max=1024", + "-Declipse.log.backup.max=2", + "-Declipse.log.level=WARNING", "-jar", "/jdtls/plugins/equinox.jar", "-configuration", @@ -2012,7 +2022,7 @@ mod tests { heap_and_level(&[]), ( vec!["-Xmx2048m".to_string()], - vec!["-Dlog.level=ALL".to_string()] + vec!["-Dlog.level=WARNING".to_string()] ) ); // The last acceptable choice wins; a malformed one never reaches the JVM. @@ -2038,7 +2048,7 @@ mod tests { ]), ( vec!["-Xmx2048m".to_string()], - vec!["-Dlog.level=ALL".to_string()] + vec!["-Dlog.level=WARNING".to_string()] ) ); // A plugin that launches the server as `java -jar ` diff --git a/rust/lithe-core/src/runtime/ffi.rs b/rust/lithe-core/src/runtime/ffi.rs index 33e498ef89..6fb9f83043 100644 --- a/rust/lithe-core/src/runtime/ffi.rs +++ b/rust/lithe-core/src/runtime/ffi.rs @@ -160,6 +160,35 @@ pub unsafe extern "C" fn lithe_core_execute_json_with_events( response_pointer(&crate::execute_json_with_events(&request, sink)) } +/// Installs the host log writer's callback for pending Core diagnostic facts, +/// replacing any earlier writer. Core calls it at most once per drain cycle, +/// after a fact or overflow count is queued, so the host can schedule its +/// existing drain instead of polling. A null callback is ignored. +/// +/// # Safety +/// +/// `callback` may run on any Core thread, must not unwind, and must not call +/// back into Core synchronously; it should only schedule host work. `context` +/// is passed back unchanged and must remain valid until +/// [`lithe_core_remove_log_waker`] returns for it or another install replaces it. +#[no_mangle] +pub unsafe extern "C" fn lithe_core_install_log_waker( + callback: Option, + context: *mut std::ffi::c_void, +) { + if let Some(callback) = callback { + crate::diagnostics::core_events::install_waker(callback, context as usize); + } +} + +/// Removes the log waker installed with `context`. Returns 1 when it was the +/// current waker. After return, Core never invokes the callback with `context`, +/// so the host may release it. +#[no_mangle] +pub extern "C" fn lithe_core_remove_log_waker(context: *mut std::ffi::c_void) -> i32 { + crate::diagnostics::core_events::remove_waker(context as usize) as i32 +} + /// Runs the native authentication helper without initializing the application. /// /// # Safety diff --git a/rust/lithe-core/tests/core_log_waker.rs b/rust/lithe-core/tests/core_log_waker.rs new file mode 100644 index 0000000000..e2592e60da --- /dev/null +++ b/rust/lithe-core/tests/core_log_waker.rs @@ -0,0 +1,94 @@ +//! Verifies the C ABI wake signal for the process-global Core diagnostic queue. +//! +//! The queue and waker are process globals, so this file is its own test binary +//! and keeps every assertion in one test: no other test can record or drain. + +use lithe_core::diagnostics::core_events::{drain, record}; +use serde_json::json; +use std::collections::BTreeMap; +use std::ffi::c_void; +use std::sync::atomic::{AtomicUsize, Ordering}; + +extern "C" { + fn lithe_core_install_log_waker( + callback: Option, + context: *mut c_void, + ); + fn lithe_core_remove_log_waker(context: *mut c_void) -> i32; +} + +static FIRST: AtomicUsize = AtomicUsize::new(0); +static SECOND: AtomicUsize = AtomicUsize::new(0); + +unsafe extern "C" fn count_wake(context: *mut c_void) { + (*(context as *const AtomicUsize)).fetch_add(1, Ordering::SeqCst); +} + +fn context(counter: &'static AtomicUsize) -> *mut c_void { + counter as *const AtomicUsize as *mut c_void +} + +fn record_skipped(path: &str) { + record( + "app.workspace.directory_skipped", + BTreeMap::from([ + ("path".into(), json!(path)), + ("error".into(), json!("Directory traversal failed")), + ("error_code".into(), json!("permission_denied")), + ("error_domain".into(), json!("core")), + ]), + None, + ) + .expect("catalog fact is valid"); +} + +#[test] +fn log_waker_signals_once_per_drain_cycle_and_stops_after_removal() { + let first = context(&FIRST); + let second = context(&SECOND); + unsafe { lithe_core_install_log_waker(Some(count_wake), first) }; + assert_eq!( + FIRST.load(Ordering::SeqCst), + 0, + "an empty queue needs no wake" + ); + + record_skipped("src/a"); + record_skipped("src/b"); + assert_eq!( + FIRST.load(Ordering::SeqCst), + 1, + "a burst coalesces into one wake" + ); + + assert_eq!(drain().events.len(), 2); + record_skipped("src/c"); + assert_eq!( + FIRST.load(Ordering::SeqCst), + 2, + "a drain re-arms the signal" + ); + + assert_eq!(unsafe { lithe_core_remove_log_waker(second) }, 0); + assert_eq!(drain().events.len(), 1); + record_skipped("src/d"); + assert_eq!( + FIRST.load(Ordering::SeqCst), + 3, + "a non-owner cannot remove the waker" + ); + + assert_eq!(unsafe { lithe_core_remove_log_waker(first) }, 1); + assert_eq!(drain().events.len(), 1); + record_skipped("src/e"); + assert_eq!(FIRST.load(Ordering::SeqCst), 3, "no wake after removal"); + + unsafe { lithe_core_install_log_waker(Some(count_wake), second) }; + assert_eq!( + SECOND.load(Ordering::SeqCst), + 1, + "facts recorded before install wake the new writer immediately" + ); + assert_eq!(unsafe { lithe_core_remove_log_waker(second) }, 1); + assert_eq!(drain().events.len(), 1); +} diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index e60e180153..ab35b717d1 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -394,6 +394,63 @@ backend are `plugin.launch.unavailable`. Core never creates or reads any of these paths. The command also serves API 1 manifests, whose arguments are returned unchanged. +#### Structured plugin diagnostics (logging protocol 1) + +Every plugin-owned process may negotiate `capabilities.experimental.litheLogging`: +the host offers `{ "versions": [1] }` in `initialize`, and the SDK answers +`{ "version": 1 }`. The current official SDKs require that offer. This protocol +is independent of function, build, VCS, and toolchain protocol versions. + +After successful initialization, the backend sends a JSON-RPC notification +`lithe/log`. Its params contain only these fields; unknown fields are rejected: + +| Field | Meaning | +| --- | --- | +| `kind` | `operationFinished`, `operationFailed`, `degraded`, or `recovered`. These select INFO, ERROR, WARN, and WARN catalog events respectively. | +| `operation` | A registered function ID (up to 64 bytes), `function-call` for a longer registered ID, a supported `lithe/build/*` or `lithe/vcs/*` request method, `lithe/keymap/import`, or the SDK-owned stages `initialize`, `shutdown`, `notification`, `result-encoding`. Arbitrary display labels and user input are refused. | +| `durationMs` | Required for finished/failed, absent for degraded/recovered. Monotonic elapsed integer milliseconds, 0 through 9007199254740991. | +| `code` | Required except for finished. Stable category, 1–64 ASCII bytes from letters, digits, `._:/-$`; never an exception message, command line, query, or response body. | +| `count` | Optional affected item count, a nonnegative JSON safe integer. | +| `operationId` | Optional originating wire operation ID, 1–128 ASCII token bytes. The SDK obtains it from the call context. Core resolves a still-active wire request to its host operation; unknown or retired IDs provide no correlation. | + +The notification must precede the corresponding RPC response so its wire ID +still belongs to the active host operation. Cancellation is an expected result +and does not produce an operation failure. The SDK owns automatic function and +domain request terminal facts; plugin handlers must not duplicate them. A +handler records a warning only when it actually recovers or returns a degraded +result. Frequently repeated read/preview/poll functions suppress successful +terminal facts; their failures remain observable. + +Core supplies the installed plugin ID and routes these facts to +`plugins//host`. Plugins cannot provide an event ID, channel, severity, +message, arbitrary fields, error body, or writer path. The fixed catalog events +are `plugins.operation.finished`, `plugins.operation.failed`, +`plugins.operation.fallback_selected`, and `plugins.operation.recovery_finished`. +Malformed or unnegotiated input produces one category-only +`plugins.diagnostics.rejected` warning per process session, without retaining +the rejected payload. Ingress admits at most 600 facts per minute per session, +then uses the existing bounded Core handoff to the host's single writer; +overload increments its dropped counter. No polling timer or extra writer is +created. Fixture: `fixtures/plugin-logging.json`. + +Severity follows the actual result. An operation that failed for the user is +ERROR, a recovery or fallback is WARN, and a useful successful user operation +is INFO. The source stream is not a severity: unclassified stderr is DEBUG +evidence, while standard `window/logMessage`/`window/showMessage` types retain +their explicit error/warning/info/detail semantics through separate catalog +events. Raw stdout is reserved for protocol frames. Source code, credentials, +SQL, HTTP headers/bodies, terminal history, protocol payloads, and command +output must never be included in diagnostic fields. + +Native upstream files may use `${logDir}` only when the host supplies the +current local writable session directory. They remain separate files, not +structured catalog events. Keep metadata and caches in `${stateDir}` or +`${workspaceStateDir}`, and never infer a writable log path from `${pluginDir}`. +These runtime files do not modify installed resources or participate in build +resource reuse. Hosts without a local `logDirectory` reject launches requiring +`${logDir}`. The current macOS writer persists v2; Windows's writer migration +remains a separate platform task. + #### Plugin settings (API 2) A plugin may declare options the user can change, one set per plugin and diff --git a/shared/contracts/plugins/v1/fixtures/plugin-logging.json b/shared/contracts/plugins/v1/fixtures/plugin-logging.json new file mode 100644 index 0000000000..e606b4cae3 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/plugin-logging.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "offer": { "litheLogging": { "versions": [1] } }, + "answer": { "litheLogging": { "version": 1 } }, + "notifications": [ + { "kind": "operationFinished", "operation": "database.call", "durationMs": 18, "operationId": "7" }, + { "kind": "operationFailed", "operation": "database.call", "durationMs": 20, "code": "database.unavailable", "operationId": "8" }, + { "kind": "degraded", "operation": "database.call", "code": "result_size_limited", "count": 1000, "operationId": "9" }, + { "kind": "recovered", "operation": "database.call", "code": "connection_restored" } + ], + "rejected": [ + { "kind": "operationFinished", "operation": "database.call", "durationMs": -1 }, + { "kind": "operationFailed", "operation": "database.call", "durationMs": 1, "code": "database.failed", "message": "private request contents" }, + { "kind": "operationFinished", "operation": "unregistered.query", "durationMs": 1 }, + { "kind": "degraded", "operation": "database.call", "code": "query failed with private data" }, + { "kind": "degraded", "operation": "database.call", "code": "limit", "count": 9007199254740992 } + ], + "registeredFunctions": ["database.call"], + "plugin": "io.example.database", + "events": [ + "plugins.operation.finished", + "plugins.operation.failed", + "plugins.operation.fallback_selected", + "plugins.operation.recovery_finished" + ], + "levels": ["INFO", "ERROR", "WARN", "WARN"], + "channel": "plugins/io.example.database/host" +} diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 36a367b956..1767cc60f8 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -142,6 +142,8 @@ int32_t lithe_core_git_askpass(const char *prompt); char *lithe_core_lsp_provider_catalog_json(const char *workspace_root); int32_t lithe_core_cancel(const char *operation_id); void lithe_core_free_string(char *value); +void lithe_core_install_log_waker(void (*callback)(void *), void *context); +int32_t lithe_core_remove_log_waker(void *context); void *lithe_agent_open_json(const char *configuration, void (*callback)(const char *, void *), void *context); int32_t lithe_agent_send_json(void *handle, const char *command); void lithe_agent_close(void *handle); @@ -5492,8 +5494,19 @@ facts increment a saturating `dropped` counter; draining resets the counter. The host converts `dropped` to `log.dropped` on its application channel. `record` accepts only catalog events whose owner is `lithe-core`: logging error `-32059` means Core event owner mismatch, exclusively; existing `-32053` through -`-32058` retain their catalog and collection meanings. There is no second writer, file handle, -or background task. The queue observes UTC at the Core event boundary; host +`-32058` retain their catalog and collection meanings. + +`lithe_core_install_log_waker(callback, context)` registers the host's single +log writer for a pending-fact signal; a later install replaces the earlier one +and a null callback is ignored. Core invokes the callback at most once per drain +cycle: when the first fact or drop is queued after a drain, and immediately at +install when facts are already pending. It runs synchronously on the recording +or installing thread, must not unwind or call back into Core, and only schedules +the writer's existing drain. `lithe_core_remove_log_waker(context)` returns 1 when +`context` owned the registration and 0 otherwise; after it returns, Core never +invokes the callback with `context`. The waker belongs to the existing queue: +there is no second writer, file handle, background task, or poller. The queue +observes UTC at the Core event boundary; host session sequences are assigned at drain ingestion. Project skips retain only workspace-relative paths and stable error categories; cleanup facts retain only categories, excluding remote addresses, output, and scratch names. @@ -5517,10 +5530,26 @@ resources. Validation probes cannot reference `${logDir}`. See the synchronized literal. Wire examples are exercised by `shared/fixtures/diagnostics/engine-logging-v2.json` and Core tests. -macOS PR3 passes `CoreLogRecord` directly to the injected `LogSink`. The existing -MacLogStore drain/flush/finish cadence calls `diagnostics.drainCoreEvents` once per -drain, retaining source `ts`/`op` and allocating session `seq`. It creates no idle -poller or second writer. `channelCategory` maps lsp to language, vcs to git and +Plugin-owned sessions offer `litheLogging` 1 independently of other protocols. +The negotiated `lithe/log` notification is validated in Core and enters the +same bounded `diagnostics.drainCoreEvents` queue, with the installed plugin +identity selecting `plugins//host`. It never copies notification JSON into +legacy runtime detail. The four facts map to catalog INFO/ERROR/WARN/WARN, +and a live wire request ID resolves to its original host `op`; no user content +or backend-selected channel/level/message is accepted. See the +[plugin logging contract](plugins/v1/README.md#structured-plugin-diagnostics-logging-protocol-1) +and `plugins/v1/fixtures/plugin-logging.json`. Explicit upstream LSP message +types retain their severity through separate catalog events; unclassified +stderr is DEBUG and actual lifecycle failure is ERROR. + +macOS PR3 passes `CoreLogRecord` directly to the injected `LogSink`. Every +MacLogStore drain, flush and finish calls `diagnostics.drainCoreEvents` once, +retaining source `ts`/`op` and allocating session `seq`. Only the application +writer installs the Core log waker; its callback schedules the same 100 ms +one-shot drain that host events use, so Core and plugin facts recorded without +host activity reach disk and live subscribers without an idle poller or second +writer. Finishing the session removes the waker before the final drain. +`channelCategory` maps lsp to language, vcs to git and plugin-host processes to plugins. The selected diagnostic category drives `lsp.setDetailedDiagnostics` for active sessions, newly registered sessions and a one-shot 15-minute expiry. A failed desired state is reported once with diff --git a/shared/fixtures/diagnostics/logging-v2.json b/shared/fixtures/diagnostics/logging-v2.json index 37bfc9372d..77d27e6916 100644 --- a/shared/fixtures/diagnostics/logging-v2.json +++ b/shared/fixtures/diagnostics/logging-v2.json @@ -1049,7 +1049,7 @@ }, { "event": "lsp.stderr.received", - "level": "WARN", + "level": "DEBUG", "fields": { "server": "fixture" }, diff --git a/shared/logging/README.md b/shared/logging/README.md index e982e165fb..9f937955b7 100644 --- a/shared/logging/README.md +++ b/shared/logging/README.md @@ -89,6 +89,8 @@ CI 的 Draft PR 只跑 Linux,ready PR 和 preview push 跑三平台。输入 PR3 引擎采用按角色事件族:`lsp.*` 使用 `lsp/{server}`,`vcs.*` 使用 `vcs/{provider}`,`plugins.host.*` 使用 `plugins/{plugin}/host`。Core 从显式 role 与 catalog 身份字段选事件,目录模板仍是通道唯一来源;不允许任意输入 channel 改写普通事件。非法段使用稳定的 SHA-256 前缀身份,不丢弃日志或影响插件启动。stderr 的 reader 限制为每行 4 KiB、每秒 200 行,再走目录 text 的 1 KiB 脱敏规范化。trace 默认关闭,宿主通过 `lsp.setDetailedDiagnostics` 显式启用;`lsp.session.updated` 的 owner 已转交 Core,宿主不再重复发同一状态事实。 +上游明确严重度通过独立目录事件保留:`window/logMessage` 和 `window/showMessage` 的 type 1/2/3/4 分别为 ERROR/WARN/INFO/DEBUG,未知类型为 DEBUG。未分类 stderr 是 DEBUG 排障证据,流名称不表示警告;实际进程或协议失败由 `*.session.failed` 记录 ERROR,正常关闭仍是生命周期状态。插件自身使用独立协商的 `litheLogging` 1 与 `lithe/log`,由 Core 把四种受控事实映射到插件目录事件,不能再用 stderr 或任意自由消息代替。协议字段、op、限额与 fixture 以 [插件契约](../contracts/plugins/v1/README.md#structured-plugin-diagnostics-logging-protocol-1) 为准。 + 详细诊断只接纳 `$/logTrace.message` 摘要;`verbose` 可能包含原始 RPC 载荷和文档源码,禁止进入 v2 text。凭据脱敏和 1 KiB 限长不代表源码可以落盘。初始化完成前的开关只保存目标状态,Core 在 `initialized` 之后发送通知。 PR3 macOS 将 CoreLogRecord 原样交给统一 LogSink/MacLogStore,按现有 drain、flush 和退出节奏一次排空 Core 队列,保留 ts/op、分配 seq,并继续 validateCatalog:true;不新增空闲轮询或独立 writer。启动者显式声明 role、真实 pluginId 与 VCS logSubject;宿主不解析 providerID。详细诊断类别沿用 lsp→language、vcs→git、plugins→plugins,对活动和新会话发送 lsp.setDetailedDiagnostics,15 分钟单次到期关闭,失败以 app.logging.diagnostics_failed 记录一次。 diff --git a/shared/logging/events/debug.json b/shared/logging/events/debug.json index be17c607b0..be45697cf4 100644 --- a/shared/logging/events/debug.json +++ b/shared/logging/events/debug.json @@ -61,6 +61,151 @@ }, "rate": 60, "owner": "Lithe" + }, + { + "id": "debug.process.started", + "channel": "debug/{adapter}", + "level": "info", + "message": "Debug adapter session created", + "fields": { + "adapter": { + "type": "id", + "required": true, + "description": "Validated DAP adapter identity selected by the debug session; unsafe names use a stable SHA-256 segment" + } + }, + "rate": 120, + "owner": "lithe-core" + }, + { + "id": "debug.session.initialized", + "channel": "debug/{adapter}", + "level": "info", + "message": "Debug adapter session ready", + "fields": { + "adapter": { + "type": "id", + "required": true, + "description": "Validated DAP adapter identity selected by the debug session; unsafe names use a stable SHA-256 segment" + } + }, + "rate": 120, + "owner": "lithe-core" + }, + { + "id": "debug.session.finished", + "channel": "debug/{adapter}", + "level": "info", + "message": "Debug adapter session terminated", + "fields": { + "adapter": { + "type": "id", + "required": true, + "description": "Validated DAP adapter identity selected by the debug session; unsafe names use a stable SHA-256 segment" + } + }, + "rate": 120, + "owner": "lithe-core" + }, + { + "id": "debug.session.state_changed", + "channel": "debug/{adapter}", + "level": "debug", + "message": "Debug adapter state changed", + "fields": { + "adapter": { + "type": "id", + "required": true, + "description": "Validated DAP adapter identity selected by the debug session; unsafe names use a stable SHA-256 segment" + }, + "state": { + "type": "token", + "required": true, + "description": "Core DAP lifecycle state reduced from the protocol, such as initializing, ready, running or terminated" + } + }, + "rate": 120, + "owner": "lithe-core" + }, + { + "id": "debug.session.failed", + "channel": "debug/{adapter}", + "level": "error", + "message": "Debug adapter session failed", + "fields": { + "adapter": { + "type": "id", + "required": true, + "description": "Validated DAP adapter identity selected by the debug session; unsafe names use a stable SHA-256 segment" + }, + "failure_code": { + "type": "token", + "required": true, + "description": "Controlled session failure category, protocol or adapter_rejected; no adapter response text" + } + }, + "rate": 120, + "owner": "lithe-core" + }, + { + "id": "debug.operation.failed", + "channel": "debug/{adapter}", + "level": "error", + "message": "Debug adapter operation failed", + "fields": { + "adapter": { + "type": "id", + "required": true, + "description": "Validated DAP adapter identity selected by the debug session; unsafe names use a stable SHA-256 segment" + }, + "failure_code": { + "type": "token", + "required": true, + "description": "Stable Core operation failure classification, adapter_rejected or timed_out; no adapter message" + } + }, + "rate": 120, + "owner": "lithe-core" + }, + { + "id": "debug.operation.cancelled", + "channel": "debug/{adapter}", + "level": "debug", + "message": "Debug adapter operation discarded", + "fields": { + "adapter": { + "type": "id", + "required": true, + "description": "Validated DAP adapter identity selected by the debug session; unsafe names use a stable SHA-256 segment" + }, + "failure_code": { + "type": "token", + "required": true, + "description": "Expected terminal classification, cancelled or stale_reference; no protocol payload" + } + }, + "rate": 120, + "owner": "lithe-core" + }, + { + "id": "debug.transport.failed", + "channel": "debug", + "level": "error", + "message": "Debug adapter transport failed", + "fields": { + "stage": { + "type": "token", + "required": true, + "description": "Native failure stage, process-start, initialization-timeout, transport-exit or transport-receive" + }, + "exit_code": { + "type": "int", + "required": false, + "description": "Native adapter process exit status when available; absent for startup, timeout and receive failures" + } + }, + "rate": 60, + "owner": "LitheDebugModule" } ] } diff --git a/shared/logging/events/lsp.json b/shared/logging/events/lsp.json index 7c1aa5c3da..60c7084e10 100644 --- a/shared/logging/events/lsp.json +++ b/shared/logging/events/lsp.json @@ -645,7 +645,7 @@ { "id": "lsp.stderr.received", "channel": "lsp/{server}", - "level": "warn", + "level": "debug", "message": "Language server stderr received for server {server}", "fields": { "server": { @@ -656,7 +656,7 @@ "diagnostic": { "type": "text", "required": true, - "description": "Original language server stderr diagnostic, redacted and capped at 1024 UTF-8 bytes", + "description": "Unclassified upstream stderr diagnostic; retained only for detailed diagnosis and never treated as an operation failure", "reason": "Language servers emit implementation-specific failure explanations only on stderr, so retain the original diagnostic wording for diagnosis after redaction and truncation rather than attempting an unstable parser." }, "truncated": { @@ -952,6 +952,186 @@ "description": "Stable native cleanup failure category; excludes process arguments and paths" } } + }, + { + "id": "lsp.message.error.logged", + "channel": "lsp/{server}", + "level": "error", + "message": "Language server log message received for server {server} with severity {severity}", + "fields": { + "server": { + "type": "id", + "required": true, + "description": "Validated language server session id that sent window/logMessage" + }, + "diagnostic": { + "type": "text", + "required": true, + "description": "Original message from the window/logMessage params, redacted and capped at 1024 UTF-8 bytes", + "reason": "The language server owns the unstructured window/logMessage explanation and exposes no stable error code for it, so preserve its original wording after redaction and truncation." + }, + "severity": { + "type": "token", + "required": true, + "description": "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log" + } + }, + "rate": 120, + "owner": "lithe-core" + }, + { + "id": "lsp.message.warning.logged", + "channel": "lsp/{server}", + "level": "warn", + "message": "Language server log message received for server {server} with severity {severity}", + "fields": { + "server": { + "type": "id", + "required": true, + "description": "Validated language server session id that sent window/logMessage" + }, + "diagnostic": { + "type": "text", + "required": true, + "description": "Original message from the window/logMessage params, redacted and capped at 1024 UTF-8 bytes", + "reason": "The language server owns the unstructured window/logMessage explanation and exposes no stable error code for it, so preserve its original wording after redaction and truncation." + }, + "severity": { + "type": "token", + "required": true, + "description": "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log" + } + }, + "rate": 120, + "owner": "lithe-core" + }, + { + "id": "lsp.message.detail.logged", + "channel": "lsp/{server}", + "level": "debug", + "message": "Language server log message received for server {server} with severity {severity}", + "fields": { + "server": { + "type": "id", + "required": true, + "description": "Validated language server session id that sent window/logMessage" + }, + "diagnostic": { + "type": "text", + "required": true, + "description": "Original message from the window/logMessage params, redacted and capped at 1024 UTF-8 bytes", + "reason": "The language server owns the unstructured window/logMessage explanation and exposes no stable error code for it, so preserve its original wording after redaction and truncation." + }, + "severity": { + "type": "token", + "required": true, + "description": "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log" + } + }, + "rate": 120, + "owner": "lithe-core" + }, + { + "id": "lsp.message.error.shown", + "channel": "lsp/{server}", + "level": "error", + "message": "Language server show message received for server {server} with severity {severity}", + "fields": { + "server": { + "type": "id", + "required": true, + "description": "Validated language server session id that sent window/showMessage" + }, + "diagnostic": { + "type": "text", + "required": true, + "description": "Original message from the window/showMessage params, redacted and capped at 1024 UTF-8 bytes", + "reason": "The language server owns the window/showMessage explanation shown to the user; retain its original wording after redaction and truncation to correlate a reported message with the server session." + }, + "severity": { + "type": "token", + "required": true, + "description": "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log" + } + }, + "rate": 120, + "owner": "lithe-core" + }, + { + "id": "lsp.message.warning.shown", + "channel": "lsp/{server}", + "level": "warn", + "message": "Language server show message received for server {server} with severity {severity}", + "fields": { + "server": { + "type": "id", + "required": true, + "description": "Validated language server session id that sent window/showMessage" + }, + "diagnostic": { + "type": "text", + "required": true, + "description": "Original message from the window/showMessage params, redacted and capped at 1024 UTF-8 bytes", + "reason": "The language server owns the window/showMessage explanation shown to the user; retain its original wording after redaction and truncation to correlate a reported message with the server session." + }, + "severity": { + "type": "token", + "required": true, + "description": "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log" + } + }, + "rate": 120, + "owner": "lithe-core" + }, + { + "id": "lsp.message.detail.shown", + "channel": "lsp/{server}", + "level": "debug", + "message": "Language server show message received for server {server} with severity {severity}", + "fields": { + "server": { + "type": "id", + "required": true, + "description": "Validated language server session id that sent window/showMessage" + }, + "diagnostic": { + "type": "text", + "required": true, + "description": "Original message from the window/showMessage params, redacted and capped at 1024 UTF-8 bytes", + "reason": "The language server owns the window/showMessage explanation shown to the user; retain its original wording after redaction and truncation to correlate a reported message with the server session." + }, + "severity": { + "type": "token", + "required": true, + "description": "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log" + } + }, + "rate": 120, + "owner": "lithe-core" + }, + { + "id": "lsp.session.failed", + "channel": "lsp/{server}", + "level": "error", + "owner": "lithe-core", + "message": "Engine session failed", + "fields": { + "server": { + "type": "id", + "required": true, + "description": "Language provider identity from the registered host provider catalog." + }, + "state": { + "type": "token", + "required": true, + "description": "Core session lifecycle state emitted by the managed engine (for example ready or stopped)." + }, + "code": { + "type": "token", + "required": true, + "description": "Stable Core lifecycle failure category, such as serverExited or transportFailed, without protocol or process contents" + } + } } ] } diff --git a/shared/logging/events/plugins.json b/shared/logging/events/plugins.json index cc534ea0c5..899b1a22ef 100644 --- a/shared/logging/events/plugins.json +++ b/shared/logging/events/plugins.json @@ -299,13 +299,13 @@ { "id": "plugins.host.stderr.received", "channel": "plugins/{plugin}/host", - "level": "warn", + "level": "debug", "message": "Engine stderr received for server {plugin}", "fields": { "diagnostic": { "type": "text", "required": true, - "description": "Original language server stderr diagnostic, redacted and capped at 1024 UTF-8 bytes", + "description": "Unclassified upstream stderr diagnostic; retained only for detailed diagnosis and never treated as an operation failure", "reason": "Language servers emit implementation-specific failure explanations only on stderr, so retain the original diagnostic wording for diagnosis after redaction and truncation rather than attempting an unstable parser." }, "truncated": { @@ -563,6 +563,331 @@ "description": "Stable native cleanup failure category; excludes process arguments and paths" } } + }, + { + "id": "plugins.operation.finished", + "channel": "plugins/{plugin}/host", + "level": "info", + "message": "Plugin operation finished", + "fields": { + "plugin": { + "type": "id", + "required": true, + "description": "Installed plugin identity supplied by the owning Core session, never by the notification" + }, + "operation": { + "type": "token", + "required": true, + "description": "Registered plugin function or SDK protocol operation that owns this diagnostic result" + }, + "duration": { + "type": "duration", + "required": true, + "description": "Monotonic elapsed milliseconds measured by the SDK for the completed plugin operation" + }, + "count": { + "type": "int", + "required": false, + "description": "Number of affected items reported by the plugin at this result boundary; absent when unknown" + } + }, + "rate": 600, + "owner": "lithe-core" + }, + { + "id": "plugins.operation.failed", + "channel": "plugins/{plugin}/host", + "level": "error", + "message": "Plugin operation failed", + "fields": { + "plugin": { + "type": "id", + "required": true, + "description": "Installed plugin identity supplied by the owning Core session, never by the notification" + }, + "operation": { + "type": "token", + "required": true, + "description": "Registered plugin function or SDK protocol operation that owns this diagnostic result" + }, + "duration": { + "type": "duration", + "required": true, + "description": "Monotonic elapsed milliseconds measured by the SDK for the completed plugin operation" + }, + "code": { + "type": "token", + "required": true, + "description": "Stable plugin failure or recovery category, excluding upstream error messages and request contents" + }, + "count": { + "type": "int", + "required": false, + "description": "Number of affected items reported by the plugin at this result boundary; absent when unknown" + } + }, + "rate": 600, + "owner": "lithe-core" + }, + { + "id": "plugins.operation.fallback_selected", + "channel": "plugins/{plugin}/host", + "level": "warn", + "message": "Plugin operation used a degraded result", + "fields": { + "plugin": { + "type": "id", + "required": true, + "description": "Installed plugin identity supplied by the owning Core session, never by the notification" + }, + "operation": { + "type": "token", + "required": true, + "description": "Registered plugin function or SDK protocol operation that owns this diagnostic result" + }, + "code": { + "type": "token", + "required": true, + "description": "Stable plugin failure or recovery category, excluding upstream error messages and request contents" + }, + "count": { + "type": "int", + "required": false, + "description": "Number of affected items reported by the plugin at this result boundary; absent when unknown" + } + }, + "rate": 600, + "owner": "lithe-core" + }, + { + "id": "plugins.operation.recovery_finished", + "channel": "plugins/{plugin}/host", + "level": "warn", + "message": "Plugin operation recovered", + "fields": { + "plugin": { + "type": "id", + "required": true, + "description": "Installed plugin identity supplied by the owning Core session, never by the notification" + }, + "operation": { + "type": "token", + "required": true, + "description": "Registered plugin function or SDK protocol operation that owns this diagnostic result" + }, + "code": { + "type": "token", + "required": true, + "description": "Stable plugin failure or recovery category, excluding upstream error messages and request contents" + }, + "count": { + "type": "int", + "required": false, + "description": "Number of affected items reported by the plugin at this result boundary; absent when unknown" + } + }, + "rate": 600, + "owner": "lithe-core" + }, + { + "id": "plugins.diagnostics.rejected", + "channel": "plugins/{plugin}/host", + "level": "warn", + "message": "Plugin diagnostic input rejected", + "fields": { + "plugin": { + "type": "id", + "required": true, + "description": "Installed plugin identity whose unnegotiated or malformed diagnostic was rejected" + }, + "code": { + "type": "token", + "required": true, + "description": "Fixed rejection category invalid_diagnostic; the rejected private payload is never retained" + } + }, + "rate": 1, + "owner": "lithe-core" + }, + { + "id": "plugins.host.message.error.logged", + "channel": "plugins/{plugin}/host", + "level": "error", + "message": "Engine log message received for server {plugin} with severity {severity}", + "fields": { + "diagnostic": { + "type": "text", + "required": true, + "description": "Original message from the window/logMessage params, redacted and capped at 1024 UTF-8 bytes", + "reason": "The language server owns the unstructured window/logMessage explanation and exposes no stable error code for it, so preserve its original wording after redaction and truncation." + }, + "severity": { + "type": "token", + "required": true, + "description": "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log" + }, + "plugin": { + "type": "id", + "required": true, + "description": "Validated plugin identity assigned by Core to this engine session" + } + }, + "rate": 120, + "owner": "lithe-core" + }, + { + "id": "plugins.host.message.warning.logged", + "channel": "plugins/{plugin}/host", + "level": "warn", + "message": "Engine log message received for server {plugin} with severity {severity}", + "fields": { + "diagnostic": { + "type": "text", + "required": true, + "description": "Original message from the window/logMessage params, redacted and capped at 1024 UTF-8 bytes", + "reason": "The language server owns the unstructured window/logMessage explanation and exposes no stable error code for it, so preserve its original wording after redaction and truncation." + }, + "severity": { + "type": "token", + "required": true, + "description": "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log" + }, + "plugin": { + "type": "id", + "required": true, + "description": "Validated plugin identity assigned by Core to this engine session" + } + }, + "rate": 120, + "owner": "lithe-core" + }, + { + "id": "plugins.host.message.detail.logged", + "channel": "plugins/{plugin}/host", + "level": "debug", + "message": "Engine log message received for server {plugin} with severity {severity}", + "fields": { + "diagnostic": { + "type": "text", + "required": true, + "description": "Original message from the window/logMessage params, redacted and capped at 1024 UTF-8 bytes", + "reason": "The language server owns the unstructured window/logMessage explanation and exposes no stable error code for it, so preserve its original wording after redaction and truncation." + }, + "severity": { + "type": "token", + "required": true, + "description": "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log" + }, + "plugin": { + "type": "id", + "required": true, + "description": "Validated plugin identity assigned by Core to this engine session" + } + }, + "rate": 120, + "owner": "lithe-core" + }, + { + "id": "plugins.host.message.error.shown", + "channel": "plugins/{plugin}/host", + "level": "error", + "message": "Engine show message received for server {plugin} with severity {severity}", + "fields": { + "diagnostic": { + "type": "text", + "required": true, + "description": "Original message from the window/showMessage params, redacted and capped at 1024 UTF-8 bytes", + "reason": "The language server owns the window/showMessage explanation shown to the user; retain its original wording after redaction and truncation to correlate a reported message with the server session." + }, + "severity": { + "type": "token", + "required": true, + "description": "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log" + }, + "plugin": { + "type": "id", + "required": true, + "description": "Validated plugin identity assigned by Core to this engine session" + } + }, + "rate": 120, + "owner": "lithe-core" + }, + { + "id": "plugins.host.message.warning.shown", + "channel": "plugins/{plugin}/host", + "level": "warn", + "message": "Engine show message received for server {plugin} with severity {severity}", + "fields": { + "diagnostic": { + "type": "text", + "required": true, + "description": "Original message from the window/showMessage params, redacted and capped at 1024 UTF-8 bytes", + "reason": "The language server owns the window/showMessage explanation shown to the user; retain its original wording after redaction and truncation to correlate a reported message with the server session." + }, + "severity": { + "type": "token", + "required": true, + "description": "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log" + }, + "plugin": { + "type": "id", + "required": true, + "description": "Validated plugin identity assigned by Core to this engine session" + } + }, + "rate": 120, + "owner": "lithe-core" + }, + { + "id": "plugins.host.message.detail.shown", + "channel": "plugins/{plugin}/host", + "level": "debug", + "message": "Engine show message received for server {plugin} with severity {severity}", + "fields": { + "diagnostic": { + "type": "text", + "required": true, + "description": "Original message from the window/showMessage params, redacted and capped at 1024 UTF-8 bytes", + "reason": "The language server owns the window/showMessage explanation shown to the user; retain its original wording after redaction and truncation to correlate a reported message with the server session." + }, + "severity": { + "type": "token", + "required": true, + "description": "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log" + }, + "plugin": { + "type": "id", + "required": true, + "description": "Validated plugin identity assigned by Core to this engine session" + } + }, + "rate": 120, + "owner": "lithe-core" + }, + { + "id": "plugins.host.session.failed", + "channel": "plugins/{plugin}/host", + "level": "error", + "owner": "lithe-core", + "message": "Engine session failed", + "fields": { + "state": { + "type": "token", + "required": true, + "description": "Core session lifecycle state emitted by the managed engine (for example ready or stopped)." + }, + "plugin": { + "type": "id", + "required": true, + "description": "Validated plugin identity assigned by Core to this engine session" + }, + "code": { + "type": "token", + "required": true, + "description": "Stable Core lifecycle failure category, such as serverExited or transportFailed, without protocol or process contents" + } + } } ] } diff --git a/shared/logging/events/vcs.json b/shared/logging/events/vcs.json index 5c583c9fe4..e266a8d6fb 100644 --- a/shared/logging/events/vcs.json +++ b/shared/logging/events/vcs.json @@ -126,13 +126,13 @@ { "id": "vcs.stderr.received", "channel": "vcs/{provider}", - "level": "warn", + "level": "debug", "message": "Engine stderr received for server {provider}", "fields": { "diagnostic": { "type": "text", "required": true, - "description": "Original language server stderr diagnostic, redacted and capped at 1024 UTF-8 bytes", + "description": "Unclassified upstream stderr diagnostic; retained only for detailed diagnosis and never treated as an operation failure", "reason": "Language servers emit implementation-specific failure explanations only on stderr, so retain the original diagnostic wording for diagnosis after redaction and truncation rather than attempting an unstable parser." }, "truncated": { @@ -485,6 +485,186 @@ }, "rate": 60, "owner": "LitheGitModule" + }, + { + "id": "vcs.message.error.logged", + "channel": "vcs/{provider}", + "level": "error", + "message": "Engine log message received for server {provider} with severity {severity}", + "fields": { + "diagnostic": { + "type": "text", + "required": true, + "description": "Original message from the window/logMessage params, redacted and capped at 1024 UTF-8 bytes", + "reason": "The language server owns the unstructured window/logMessage explanation and exposes no stable error code for it, so preserve its original wording after redaction and truncation." + }, + "severity": { + "type": "token", + "required": true, + "description": "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log" + }, + "provider": { + "type": "id", + "required": true, + "description": "Validated provider identity assigned by Core to this engine session" + } + }, + "rate": 120, + "owner": "lithe-core" + }, + { + "id": "vcs.message.warning.logged", + "channel": "vcs/{provider}", + "level": "warn", + "message": "Engine log message received for server {provider} with severity {severity}", + "fields": { + "diagnostic": { + "type": "text", + "required": true, + "description": "Original message from the window/logMessage params, redacted and capped at 1024 UTF-8 bytes", + "reason": "The language server owns the unstructured window/logMessage explanation and exposes no stable error code for it, so preserve its original wording after redaction and truncation." + }, + "severity": { + "type": "token", + "required": true, + "description": "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log" + }, + "provider": { + "type": "id", + "required": true, + "description": "Validated provider identity assigned by Core to this engine session" + } + }, + "rate": 120, + "owner": "lithe-core" + }, + { + "id": "vcs.message.detail.logged", + "channel": "vcs/{provider}", + "level": "debug", + "message": "Engine log message received for server {provider} with severity {severity}", + "fields": { + "diagnostic": { + "type": "text", + "required": true, + "description": "Original message from the window/logMessage params, redacted and capped at 1024 UTF-8 bytes", + "reason": "The language server owns the unstructured window/logMessage explanation and exposes no stable error code for it, so preserve its original wording after redaction and truncation." + }, + "severity": { + "type": "token", + "required": true, + "description": "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log" + }, + "provider": { + "type": "id", + "required": true, + "description": "Validated provider identity assigned by Core to this engine session" + } + }, + "rate": 120, + "owner": "lithe-core" + }, + { + "id": "vcs.message.error.shown", + "channel": "vcs/{provider}", + "level": "error", + "message": "Engine show message received for server {provider} with severity {severity}", + "fields": { + "diagnostic": { + "type": "text", + "required": true, + "description": "Original message from the window/showMessage params, redacted and capped at 1024 UTF-8 bytes", + "reason": "The language server owns the window/showMessage explanation shown to the user; retain its original wording after redaction and truncation to correlate a reported message with the server session." + }, + "severity": { + "type": "token", + "required": true, + "description": "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log" + }, + "provider": { + "type": "id", + "required": true, + "description": "Validated provider identity assigned by Core to this engine session" + } + }, + "rate": 120, + "owner": "lithe-core" + }, + { + "id": "vcs.message.warning.shown", + "channel": "vcs/{provider}", + "level": "warn", + "message": "Engine show message received for server {provider} with severity {severity}", + "fields": { + "diagnostic": { + "type": "text", + "required": true, + "description": "Original message from the window/showMessage params, redacted and capped at 1024 UTF-8 bytes", + "reason": "The language server owns the window/showMessage explanation shown to the user; retain its original wording after redaction and truncation to correlate a reported message with the server session." + }, + "severity": { + "type": "token", + "required": true, + "description": "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log" + }, + "provider": { + "type": "id", + "required": true, + "description": "Validated provider identity assigned by Core to this engine session" + } + }, + "rate": 120, + "owner": "lithe-core" + }, + { + "id": "vcs.message.detail.shown", + "channel": "vcs/{provider}", + "level": "debug", + "message": "Engine show message received for server {provider} with severity {severity}", + "fields": { + "diagnostic": { + "type": "text", + "required": true, + "description": "Original message from the window/showMessage params, redacted and capped at 1024 UTF-8 bytes", + "reason": "The language server owns the window/showMessage explanation shown to the user; retain its original wording after redaction and truncation to correlate a reported message with the server session." + }, + "severity": { + "type": "token", + "required": true, + "description": "LSP MessageType mapped to a controlled severity word, e.g. error, warn, info or log" + }, + "provider": { + "type": "id", + "required": true, + "description": "Validated provider identity assigned by Core to this engine session" + } + }, + "rate": 120, + "owner": "lithe-core" + }, + { + "id": "vcs.session.failed", + "channel": "vcs/{provider}", + "level": "error", + "owner": "lithe-core", + "message": "Engine session failed", + "fields": { + "state": { + "type": "token", + "required": true, + "description": "Core session lifecycle state emitted by the managed engine (for example ready or stopped)." + }, + "provider": { + "type": "id", + "required": true, + "description": "Validated provider identity assigned by Core to this engine session" + }, + "code": { + "type": "token", + "required": true, + "description": "Stable Core lifecycle failure category, such as serverExited or transportFailed, without protocol or process contents" + } + } } ] } diff --git a/shared/platform-feature-matrix/features/diagnostics-export.json b/shared/platform-feature-matrix/features/diagnostics-export.json index be82325bbd..981f65ebd8 100644 --- a/shared/platform-feature-matrix/features/diagnostics-export.json +++ b/shared/platform-feature-matrix/features/diagnostics-export.json @@ -28,7 +28,9 @@ "macos/Sources/Lithe/Application/Features/WorkbenchNotificationFeatureModel.swift", "macos/Sources/Lithe/Views/Diagnostics/LogWindowPalette.swift", "macos/Sources/Lithe/Views/Diagnostics/LogWindowPresentation.swift", - "scripts/macos/probe/probe-macos-logging-ui.sh" + "scripts/macos/probe/probe-macos-logging-ui.sh", + "rust/lithe-core/src/lsp/interface/plugin_logging.rs", + "shared/contracts/plugins/v1/fixtures/plugin-logging.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -44,5 +46,5 @@ "verificationStatus": "pending" }, "owner": "Diagnostics", - "verification": "双端记录 language/git/run/performance 事件,按模块、当前/上次会话、级别、UTC 时间与文本筛选;确认诊断 15 分钟到期、过载不阻塞调用线程、日志受限额控制、目录切换后收集实际位置、嵌套与转义凭据不落盘;确认预览后修改源文件不改变导出快照,取消/失败清理暂存,并检查 macOS v1 日志不进入时间线和导出、仅按 30 天过期清理与收集缺失计数。 验证自定义目录中无关的 lithe.*.log 不被收集或删除、刷新期间修改条件后按钮恢复、UTC 小数精度及等价时区边界正确、200 文件上限在模块/会话筛选后应用且遗漏明确标记。 macOS 从齿轮菜单或快捷键进入独立日志窗口,Windows 仍从设置→工具→日志与诊断进入,核对中英文名称、顺序、实际诊断范围、清理全模块会话范围、目录/清理后刷新、预览条件冻结,以及取消/失败后按钮和快照恢复;原生 macOS 明暗页面与实际 ZIP 链路、Windows 前端界面交互分别验证。 macOS v2 按 sessions 会话目录查询并冻结导出,检查 Core 目录字段校验、100 ms/256 条批量、保留容量、log.dropped/log.repeated、32 个句柄 LRU、三段轮转、超时控制与跟随广播;Windows 仍使用 v1。 PR2b 回归覆盖合法 c++ 插件编辑器资源不受日志身份约束、点号活动通道与 ~N 轮转文件互不覆盖、控制取消不丢已接纳批次与丢弃汇总、退出无条件关闭订阅和句柄、多行错误经 Core 规范化后严格校验,以及 LSP/Git/下载/freeze 失败分类和数值证据。 验证 Windows 保留设备名被拒绝、溢出通道可写头并轮转、client_secret/auth_token 等前缀凭据在编码和查询时均被脱敏,以及按 ts/level 字段筛选。 检查多通道总量超过 10 MiB 时所有活动段优先,删除或原子替换已打开的日志后新事件仍落盘;Update 事件保留 Application 分类,浏览器 c++ 与超长语言 ID 语法失败始终保留插件证据。 PR4 B2:macOS 导出使用 Core 范围规划和精确级别过滤,确认前冻结脱敏内容,附带可选环境与有界崩溃报告;macOS 设置已移除日志与诊断页,所有导出入口先打开独立日志窗口并在窗口中确认导出,通知保留原动作并提供目录事件引用跳转。运行 DiagnosticsExportV2Tests、DiagnosticsWorkflowTests、WorkbenchNotificationTests 和 probe-macos-logging-ui.sh,核对中英文 × 深浅色窗口及导出对话框。真实应用保存面板、访达、通知定位及崩溃后验收仍 pending;Windows 仍是 v1,未迁移。 PR4 视觉收尾:日志窗口按 HTML 原型使用独立明暗调色板、可换行工具栏、原生会话路径标题和当前快捷键徽标、212pt 分组通道栏、22pt 虚拟行、自绘选中状态、Core JSON 着色详情及调用链、24pt 状态栏和 560pt 导出对话框。logWindowMatchesMockup 使用真实 MacLogStore 与目录事件渲染 1340×786,中英文×明暗、调用链和单独导出截图由本地 probe 生成;截图实机比对与原生标题栏验收保持 pending。 PR4 收尾:macOS 独立日志窗口提供未开启时的详细诊断卡片及工具栏目录与清理菜单,确认当前实际目录只读呈现、选择后切换并保存、恢复默认仅在非默认目录可用、清理全会话后刷新占用;应用菜单导出在日志窗口中呈现。 修复验收:框选窗口时间后导出当前过滤结果,验证 Core 同时应用导出范围与窗口时间范围,含带时区和不重叠的空结果;原有范围、通道、级别和表达式保持交集。" + "verification": "双端记录 language/git/run/performance 事件,按模块、当前/上次会话、级别、UTC 时间与文本筛选;确认诊断 15 分钟到期、过载不阻塞调用线程、日志受限额控制、目录切换后收集实际位置、嵌套与转义凭据不落盘;确认预览后修改源文件不改变导出快照,取消/失败清理暂存,并检查 macOS v1 日志不进入时间线和导出、仅按 30 天过期清理与收集缺失计数。 验证自定义目录中无关的 lithe.*.log 不被收集或删除、刷新期间修改条件后按钮恢复、UTC 小数精度及等价时区边界正确、200 文件上限在模块/会话筛选后应用且遗漏明确标记。 macOS 从齿轮菜单或快捷键进入独立日志窗口,Windows 仍从设置→工具→日志与诊断进入,核对中英文名称、顺序、实际诊断范围、清理全模块会话范围、目录/清理后刷新、预览条件冻结,以及取消/失败后按钮和快照恢复;原生 macOS 明暗页面与实际 ZIP 链路、Windows 前端界面交互分别验证。 macOS v2 按 sessions 会话目录查询并冻结导出,检查 Core 目录字段校验、100 ms/256 条批量、保留容量、log.dropped/log.repeated、32 个句柄 LRU、三段轮转、超时控制与跟随广播;Windows 仍使用 v1。 PR2b 回归覆盖合法 c++ 插件编辑器资源不受日志身份约束、点号活动通道与 ~N 轮转文件互不覆盖、控制取消不丢已接纳批次与丢弃汇总、退出无条件关闭订阅和句柄、多行错误经 Core 规范化后严格校验,以及 LSP/Git/下载/freeze 失败分类和数值证据。 验证 Windows 保留设备名被拒绝、溢出通道可写头并轮转、client_secret/auth_token 等前缀凭据在编码和查询时均被脱敏,以及按 ts/level 字段筛选。 检查多通道总量超过 10 MiB 时所有活动段优先,删除或原子替换已打开的日志后新事件仍落盘;Update 事件保留 Application 分类,浏览器 c++ 与超长语言 ID 语法失败始终保留插件证据。 PR4 B2:macOS 导出使用 Core 范围规划和精确级别过滤,确认前冻结脱敏内容,附带可选环境与有界崩溃报告;macOS 设置已移除日志与诊断页,所有导出入口先打开独立日志窗口并在窗口中确认导出,通知保留原动作并提供目录事件引用跳转。运行 DiagnosticsExportV2Tests、DiagnosticsWorkflowTests、WorkbenchNotificationTests 和 probe-macos-logging-ui.sh,核对中英文 × 深浅色窗口及导出对话框。真实应用保存面板、访达、通知定位及崩溃后验收仍 pending;Windows 仍是 v1,未迁移。 PR4 视觉收尾:日志窗口按 HTML 原型使用独立明暗调色板、可换行工具栏、原生会话路径标题和当前快捷键徽标、212pt 分组通道栏、22pt 虚拟行、自绘选中状态、Core JSON 着色详情及调用链、24pt 状态栏和 560pt 导出对话框。logWindowMatchesMockup 使用真实 MacLogStore 与目录事件渲染 1340×786,中英文×明暗、调用链和单独导出截图由本地 probe 生成;截图实机比对与原生标题栏验收保持 pending。 PR4 收尾:macOS 独立日志窗口提供未开启时的详细诊断卡片及工具栏目录与清理菜单,确认当前实际目录只读呈现、选择后切换并保存、恢复默认仅在非默认目录可用、清理全会话后刷新占用;应用菜单导出在日志窗口中呈现。 修复验收:框选窗口时间后导出当前过滤结果,验证 Core 同时应用导出范围与窗口时间范围,含带时区和不重叠的空结果;原有范围、通道、级别和表达式保持交集。 官方插件日志接入:三SDK协商litheLogging 1,lithe/log由Core校验后进入统一队列,真实SDK/Core验证INFO成功、ERROR失败、WARN降级恢复、op与耗时、不含请求正文;高频成功可单次抑制。上游明确type对应ERROR/WARN/INFO/DEBUG,未分类stderr为DEBUG,进程实际失败为ERROR。检查日志窗口按插件与级别筛选;macOS整机窗口与Windows v2验收仍pending。" } diff --git a/shared/platform-feature-matrix/features/java-support-plugin.json b/shared/platform-feature-matrix/features/java-support-plugin.json index 526c4b531c..64e9263770 100644 --- a/shared/platform-feature-matrix/features/java-support-plugin.json +++ b/shared/platform-feature-matrix/features/java-support-plugin.json @@ -2,7 +2,7 @@ "id": "java-support-plugin", "area": "语言支持", "group": "Java", - "capability": "Java 语言服务器作为随应用发行的声明式插件:JDT LS、Lombok、调试与测试扩展及其 JDK 由插件包拥有,可卸载和恢复;运行、测试、调试仍由宿主内置实现提供,由插件在 jdt 集成的 capabilities 中声明;支持 packageTrust 1 的宿主按来源校验内置副本与发布者签名更新,安装时验证自带运行时和入口;双架构独立发布配置已准备但尚未启用上架;同 ID 安装保留默认启用和显式禁用,卸载不重新启用内置副本;缺包按需提示与模板安装入口;完整双架构索引上架后构建自动省去内置 JDT/JDK", + "capability": "Java 语言服务器作为随应用发行的声明式插件:JDT LS、Lombok、调试与测试扩展及其 JDK 由插件包拥有,可卸载和恢复;运行、测试、调试仍由宿主内置实现提供,由插件在 jdt 集成的 capabilities 中声明;支持 packageTrust 1 的宿主按来源校验内置副本与发布者签名更新,安装时验证自带运行时和入口;双架构独立发布配置已准备但尚未启用上架;同 ID 安装保留默认启用和显式禁用,卸载不重新启用内置副本;缺包按需提示与模板安装入口;完整双架构索引上架后构建自动省去内置 JDT/JDK;JDT/Equinox 原生日志使用宿主当前会话 files 目录,默认WARNING,禁记命令行并限制轮转", "macos": { "evidence": [ "Plugins/mac/Official/JavaSupport/plugin.json", @@ -42,5 +42,5 @@ "verificationStatus": "not-applicable" }, "owner": "Java Support", - "verification": "macOS:全新安装后打开插件管理,确认 Java Support 已安装且默认启用;打开 Maven 项目并点开 .java 文件,确认语言服务器启动,补全、诊断、跳转到 JDK 与依赖 jar 中的类(jdt://)、Lombok 生成的方法、运行标记可用;在打包出的应用里逐项确认宿主能力:工具栏调试能停在断点并继续,测试面板列出并能运行 Java 测试,“当前文件”与 Java main 运行配置能启动(Spring Boot 等 Maven 配置走另一条路径,不能替代这一项);用 lsof 或进程参数确认 JDT LS 及其 java 进程来自 Lithe.app/Contents/Resources/OfficialPlugins/dev.lithe.plugin.java-support,且应用内不存在 Contents/Resources/LanguageServers。卸载 Java Support 并重启:打开 .java 文件时提示需要安装或恢复该插件,语法高亮、Maven 面板和其他语言不受影响,/Lithe/PluginState 下该插件的目录被删除,app bundle 文件清单不变(scripts/macos/verify/verify-runtime-bundle-immutability.sh)。点击恢复并重启后 Java 功能恢复。从旧版本升级:首次打开 Java 项目会重建一次索引,之后正常。Windows:JDT LS 与 JDK 仍随应用安装目录分发,尚未接入该插件契约。 独立分发第一阶段:PluginBundledPublisherTrustTests 使用真实 Core 和注入测试公钥覆盖内置副本、已签名更新、用户目录伪造 bundled 来源、缺失运行时与篡改拒绝;官方发布索引回归验证双架构哈希与 tag 对应 API/requires。正式发布包、Intel 真机和完整产品下载迁移仍待验收,releaseEnabled 保持 false。 后续迁移回归覆盖默认启用/显式禁用、跳过过渡版的安装、旧移除标记不清理新状态、卸载更新不恢复内置副本;缺包提示不触发自动安装。verify-macos-package.sh 的默认与 --slim 模式分别验证空索引保留资源及完整 fixture 索引省去资源;fixture 不代表真实市场已经上线。 安装入口与实时插件激活共用同一安装流程:普通、市场、磁盘安装各覆盖默认启用和显式禁用选择,成功后迁移并保留包字节,失败不写偏好;不改变其他插件的实时激活/撤回规则,Java 宿主集成仍等待重启。 缺包判断按安装状态而非快照 ID:真实安装→卸载→prepareForLaunch→重新扫描后,.removed 内置快照不再抑制 Java 文件和项目提示;已安装(含禁用)、暂存更新及仍待重启的卸载继续使用原管理流程。状态矩阵覆盖两种启用选择与四种 Java/Maven/Gradle 文件;深浅主题原生视图验证缺包通知与新建项目安装按钮,只在用户点击后发出市场搜索,不自动安装或执行工具。 macOS:Java Support 插件设置中的类和方法跳转默认关闭;启用后 Command 悬停/点击和定义命令可用,关闭及禁用插件后释放专属交互并拒绝旧回复,现有标签和分屏不关闭。插件详情及 Java 设置页共用插件值,不保留编辑器全局开关。settings 3 未在 Windows 声明,旧宿主须拒绝此能力要求。 全局跳转开关同步所有已有项目窗口,新项目读取存储值;双项目反复切换验证旧操作失效与对象释放。未声明 editorNavigation 的语言保留原有点击/F12,包含 MyBatis XML 属性、Spring properties/YAML 和其他 LSP;不增加其悬停查询。WebKit 100 次切换验证监听恢复基线、窗口监听清零及模型/编辑器数量不增长;该结果不是长期堆内存采样。 回归选择器接受虚拟源码目标后到达关闭回调,延迟源码仍打开;加载期间关闭功能则丢弃回复,用户取消仍清理 operation。真实 WebKit 验证插件语言 bash/properties 与 Monaco shell/ini 不同,以及共用 ini 语法的文档和分屏独立应用策略、同模型身份切换释放旧动作。" + "verification": "macOS:全新安装后打开插件管理,确认 Java Support 已安装且默认启用;打开 Maven 项目并点开 .java 文件,确认语言服务器启动,补全、诊断、跳转到 JDK 与依赖 jar 中的类(jdt://)、Lombok 生成的方法、运行标记可用;在打包出的应用里逐项确认宿主能力:工具栏调试能停在断点并继续,测试面板列出并能运行 Java 测试,“当前文件”与 Java main 运行配置能启动(Spring Boot 等 Maven 配置走另一条路径,不能替代这一项);用 lsof 或进程参数确认 JDT LS 及其 java 进程来自 Lithe.app/Contents/Resources/OfficialPlugins/dev.lithe.plugin.java-support,且应用内不存在 Contents/Resources/LanguageServers。卸载 Java Support 并重启:打开 .java 文件时提示需要安装或恢复该插件,语法高亮、Maven 面板和其他语言不受影响,/Lithe/PluginState 下该插件的目录被删除,app bundle 文件清单不变(scripts/macos/verify/verify-runtime-bundle-immutability.sh)。点击恢复并重启后 Java 功能恢复。从旧版本升级:首次打开 Java 项目会重建一次索引,之后正常。Windows:JDT LS 与 JDK 仍随应用安装目录分发,尚未接入该插件契约。 独立分发第一阶段:PluginBundledPublisherTrustTests 使用真实 Core 和注入测试公钥覆盖内置副本、已签名更新、用户目录伪造 bundled 来源、缺失运行时与篡改拒绝;官方发布索引回归验证双架构哈希与 tag 对应 API/requires。正式发布包、Intel 真机和完整产品下载迁移仍待验收,releaseEnabled 保持 false。 后续迁移回归覆盖默认启用/显式禁用、跳过过渡版的安装、旧移除标记不清理新状态、卸载更新不恢复内置副本;缺包提示不触发自动安装。verify-macos-package.sh 的默认与 --slim 模式分别验证空索引保留资源及完整 fixture 索引省去资源;fixture 不代表真实市场已经上线。 安装入口与实时插件激活共用同一安装流程:普通、市场、磁盘安装各覆盖默认启用和显式禁用选择,成功后迁移并保留包字节,失败不写偏好;不改变其他插件的实时激活/撤回规则,Java 宿主集成仍等待重启。 缺包判断按安装状态而非快照 ID:真实安装→卸载→prepareForLaunch→重新扫描后,.removed 内置快照不再抑制 Java 文件和项目提示;已安装(含禁用)、暂存更新及仍待重启的卸载继续使用原管理流程。状态矩阵覆盖两种启用选择与四种 Java/Maven/Gradle 文件;深浅主题原生视图验证缺包通知与新建项目安装按钮,只在用户点击后发出市场搜索,不自动安装或执行工具。 macOS:Java Support 插件设置中的类和方法跳转默认关闭;启用后 Command 悬停/点击和定义命令可用,关闭及禁用插件后释放专属交互并拒绝旧回复,现有标签和分屏不关闭。插件详情及 Java 设置页共用插件值,不保留编辑器全局开关。settings 3 未在 Windows 声明,旧宿主须拒绝此能力要求。 全局跳转开关同步所有已有项目窗口,新项目读取存储值;双项目反复切换验证旧操作失效与对象释放。未声明 editorNavigation 的语言保留原有点击/F12,包含 MyBatis XML 属性、Spring properties/YAML 和其他 LSP;不增加其悬停查询。WebKit 100 次切换验证监听恢复基线、窗口监听清零及模型/编辑器数量不增长;该结果不是长期堆内存采样。 回归选择器接受虚拟源码目标后到达关闭回调,延迟源码仍打开;加载期间关闭功能则丢弃回复,用户取消仍清理 operation。真实 WebKit 验证插件语言 bash/properties 与 Monaco shell/ini 不同,以及共用 ini 语法的文档和分屏独立应用策略、同模型身份切换释放旧动作。 日志验证:最新本地宿主启动Java Support,确认osgi.logfile指向当前会话plugins//files/jdt.log,include.commandline=false,单文件1024KiB加两个备份,默认logLevel为WARNING;元数据和索引继续留在缓存/工作区状态,插件包字节不变。旧宿主/远程缺logDirectory按既有契约拒绝。本轮仅参数幂等和目录边界验证,真实JDT产生日志、轮转及UI未验收,verification保持pending。" } diff --git a/shared/platform-feature-matrix/features/lsp-status-logs.json b/shared/platform-feature-matrix/features/lsp-status-logs.json index 7ec5dc0dff..35bb5e27fb 100644 --- a/shared/platform-feature-matrix/features/lsp-status-logs.json +++ b/shared/platform-feature-matrix/features/lsp-status-logs.json @@ -33,7 +33,9 @@ "macos/Sources/Lithe/Views/Diagnostics/LogWindowPalette.swift", "macos/Sources/Lithe/Views/Diagnostics/LogWindowPresentation.swift", "macos/Tests/LitheTests/DiagnosticsWorkflowTests.swift", - "scripts/macos/probe/probe-macos-logging-ui.sh" + "scripts/macos/probe/probe-macos-logging-ui.sh", + "rust/lithe-core/src/lsp/interface/plugin_logging.rs", + "shared/contracts/plugins/v1/fixtures/plugin-logging.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -46,5 +48,5 @@ "verificationStatus": "pending" }, "owner": "Language Tooling", - "verification": "PR4 B1:查看日志通过 diagnostics.resolveEngineLogSubject 打开统一应用日志窗口并选择通道;已删除旧编辑器标签页和 100 条 v1 内存缓冲。验证同一窗口复用、记录引用定位、可重绑 ⌘⇧L、齿轮菜单顺序、Core 过滤与复制、调用链、虚拟表格滚动、实时批次、清空不删除文件。中英文、深浅主题和真实 Java / Git 整机验收保持 pending。导出对话框、设置页瘦身和 UI 探针属于 B2。状态栏继续显示已注册、已启用插件的真实状态;停止与重启保持会话身份和过期回调隔离。Windows 保持 v1,不宣称两端一致。 PR4 B2:macOS 导出使用 Core 范围规划和精确级别过滤,确认前冻结脱敏内容,附带可选环境与有界崩溃报告;macOS 设置已移除日志与诊断页,所有导出入口先打开独立日志窗口并在窗口中确认导出,通知保留原动作并提供目录事件引用跳转。运行 DiagnosticsExportV2Tests、DiagnosticsWorkflowTests、WorkbenchNotificationTests 和 probe-macos-logging-ui.sh,核对中英文 × 深浅色窗口及导出对话框。真实应用保存面板、访达、通知定位及崩溃后验收仍 pending;Windows 仍是 v1,未迁移。 PR3 审查回归:详细诊断先开启再启动慢初始化服务器,确认 initialized 之前没有 setTrace,初始化前关闭可撤销待启用状态;令日志根不可用,不使用 logDir 的插件仍能启动,依赖它的模板明确失败,恢复日志存储后可查看写入失败计数;trace verbose 中的文档源码和 RPC 正文不得进入 v2 日志。 PR4 视觉收尾:日志窗口按 HTML 原型使用独立明暗调色板、可换行工具栏、原生会话路径标题和当前快捷键徽标、212pt 分组通道栏、22pt 虚拟行、自绘选中状态、Core JSON 着色详情及调用链、24pt 状态栏和 560pt 导出对话框。logWindowMatchesMockup 使用真实 MacLogStore 与目录事件渲染 1340×786,中英文×明暗、调用链和单独导出截图由本地 probe 生成;截图实机比对与原生标题栏验收保持 pending。 PR4 收尾:macOS 独立日志窗口提供未开启时的详细诊断卡片及工具栏目录与清理菜单,确认当前实际目录只读呈现、选择后切换并保存、恢复默认仅在非默认目录可用、清理全会话后刷新占用;应用菜单导出在日志窗口中呈现。 修复验收:历史与实时批次均遵守时间框选;上一会话高序号不影响当前会话清空后的新日志;窗口查看 previous 时点击当前错误引用会切回 current;一次实时查询失败后暂停并通过重试或恢复跟随重建订阅、补读遗漏记录,关闭后无残留订阅。" + "verification": "PR4 B1:查看日志通过 diagnostics.resolveEngineLogSubject 打开统一应用日志窗口并选择通道;已删除旧编辑器标签页和 100 条 v1 内存缓冲。验证同一窗口复用、记录引用定位、可重绑 ⌘⇧L、齿轮菜单顺序、Core 过滤与复制、调用链、虚拟表格滚动、实时批次、清空不删除文件。中英文、深浅主题和真实 Java / Git 整机验收保持 pending。导出对话框、设置页瘦身和 UI 探针属于 B2。状态栏继续显示已注册、已启用插件的真实状态;停止与重启保持会话身份和过期回调隔离。Windows 保持 v1,不宣称两端一致。 PR4 B2:macOS 导出使用 Core 范围规划和精确级别过滤,确认前冻结脱敏内容,附带可选环境与有界崩溃报告;macOS 设置已移除日志与诊断页,所有导出入口先打开独立日志窗口并在窗口中确认导出,通知保留原动作并提供目录事件引用跳转。运行 DiagnosticsExportV2Tests、DiagnosticsWorkflowTests、WorkbenchNotificationTests 和 probe-macos-logging-ui.sh,核对中英文 × 深浅色窗口及导出对话框。真实应用保存面板、访达、通知定位及崩溃后验收仍 pending;Windows 仍是 v1,未迁移。 PR3 审查回归:详细诊断先开启再启动慢初始化服务器,确认 initialized 之前没有 setTrace,初始化前关闭可撤销待启用状态;令日志根不可用,不使用 logDir 的插件仍能启动,依赖它的模板明确失败,恢复日志存储后可查看写入失败计数;trace verbose 中的文档源码和 RPC 正文不得进入 v2 日志。 PR4 视觉收尾:日志窗口按 HTML 原型使用独立明暗调色板、可换行工具栏、原生会话路径标题和当前快捷键徽标、212pt 分组通道栏、22pt 虚拟行、自绘选中状态、Core JSON 着色详情及调用链、24pt 状态栏和 560pt 导出对话框。logWindowMatchesMockup 使用真实 MacLogStore 与目录事件渲染 1340×786,中英文×明暗、调用链和单独导出截图由本地 probe 生成;截图实机比对与原生标题栏验收保持 pending。 PR4 收尾:macOS 独立日志窗口提供未开启时的详细诊断卡片及工具栏目录与清理菜单,确认当前实际目录只读呈现、选择后切换并保存、恢复默认仅在非默认目录可用、清理全会话后刷新占用;应用菜单导出在日志窗口中呈现。 修复验收:历史与实时批次均遵守时间框选;上一会话高序号不影响当前会话清空后的新日志;窗口查看 previous 时点击当前错误引用会切回 current;一次实时查询失败后暂停并通过重试或恢复跟随重建订阅、补读遗漏记录,关闭后无残留订阅。 官方插件日志接入:三SDK协商litheLogging 1,lithe/log由Core校验后进入统一队列,真实SDK/Core验证INFO成功、ERROR失败、WARN降级恢复、op与耗时、不含请求正文;高频成功可单次抑制。上游明确type对应ERROR/WARN/INFO/DEBUG,未分类stderr为DEBUG,进程实际失败为ERROR。检查日志窗口按插件与级别筛选;macOS整机窗口与Windows v2验收仍pending。" } diff --git a/shared/platform-feature-matrix/features/plugin-debug-adapters.json b/shared/platform-feature-matrix/features/plugin-debug-adapters.json index 60bdb180f7..10a2229785 100644 --- a/shared/platform-feature-matrix/features/plugin-debug-adapters.json +++ b/shared/platform-feature-matrix/features/plugin-debug-adapters.json @@ -2,7 +2,7 @@ "id": "plugin-debug-adapters", "area": "运行与调试", "group": "调试器", - "capability": "插件提供调试适配器:声明式 debugAdapters 经共享 Core 校验,宿主按已安装插件拉起适配器、启动与附加调试、调试控制台提示符与补全,安装、更新、启停、卸载免重启生效;原生启动配置保留明确适配器身份,支持无源文件目标及同语言多适配器的会话路由;debugAdapters v2 绑定受限的目标参数与环境,并接收同包 Provider 原生调试准备", + "capability": "插件提供调试适配器:声明式 debugAdapters 经共享 Core 校验,宿主按已安装插件拉起适配器、启动与附加调试、调试控制台提示符与补全,安装、更新、启停、卸载免重启生效;原生启动配置保留明确适配器身份,支持无源文件目标及同语言多适配器的会话路由;debugAdapters v2 绑定受限的目标参数与环境,并接收同包 Provider 原生调试准备;DAP 生命周期与稳定失败分类进入统一结构化日志,取消不作为失败,协议和程序内容不落盘", "macos": { "evidence": [ "shared/contracts/plugins/v1/fixtures/debug-adapter.plugin.json", @@ -32,7 +32,9 @@ "rust/lithe-core/src/build/debug_preparation.rs", "macos/Sources/Lithe/Platform/MacOS/Debug/MacBuildDebugLaunchResolver.swift", "macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift", - "macos/Tests/LitheTests/MacNativeProviderDebugIntegrationTests.swift" + "macos/Tests/LitheTests/MacNativeProviderDebugIntegrationTests.swift", + "rust/lithe-core/src/debug/engine.rs", + "shared/logging/events/debug.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -48,6 +50,6 @@ "verificationStatus": "not-applicable" }, "owner": "Debug", - "verification": "macOS:安装声明了 debugAdapters 的插件,在其语言文件上启动调试,确认适配器按声明的 stdio 传输由宿主拉起并命中断点;可执行文件缺失时显示插件设置里的安装提示而不是笼统错误;声明 tcpPort 或 tcpAnnounced 传输的适配器在启动进程前被拒绝并说明原因。声明 tcpLanguageServer 传输的适配器(调试服务随同一插件的语言服务器运行,如 Java Debug Server 之于 JDT LS)不由宿主拉起进程:宿主启动该插件声明的语言服务器,用 endpoint.command 向它要端口并连接 127.0.0.1,端口无效、语言服务器不属于该插件或不可用时会话以原因结束。从保存配置直接启动且未打开源码时也会激活、登记语言服务器并探测工具;停止调试取消端口命令而不停止共享语言服务器,拒绝工作区或会话替换后的旧端口。适配器只声明 attach,或 selectors 的 targetKinds 不含 file 时,不会被选作文件启动的调试器;Java 在迁入 Java Support 插件前仍使用宿主自带适配器。capabilities.disabled 中的能力在调试会话里不可用,并随 debug.createSession 传给 Core。会话进行中禁用、更新或卸载该插件,或在模块设置里停用它,只停止该插件拥有的会话,同语言其他插件的会话继续运行。启动后 app bundle 文件清单不变(scripts/macos/verify/verify-runtime-bundle-immutability.sh)。远程 attach 目前只有 Core 契约:attach 配置可声明 targetBindings.connection,Core 校验主机、端口与路径映射并按适配器自己的参数路径写入启动参数,非 localhost、127.0.0.0/8、::1 的端点返回 remote:true;macOS 宿主尚未接入 attach 与“附加到远程”对话框,因此本项暂不能在应用里验证远程附加,接入后补充:连接新的非回环端点前必须弹确认且默认不记住,附加结束只断开不结束远端程序。Windows:宿主尚未接入 debugAdapters,Core 校验通过不代表 Windows 能调试插件语言。 原生宿主路由回归:使用 shared/fixtures/debug/adapter-selection-v1.json 验证自动文件选择、明确身份、无源文件目标、空身份、已撤回身份和 launch/attach 不匹配;同语言多个适配器分别收到断点,重试保持身份,附加会话启动失败保留原会话。这里只验证宿主交接与受控会话,Provider 原生程序准备 RPC 有受控流程与真实 SDK 进程覆盖;真实适配器的完整工作流与 Windows 接入仍未验收。 真实原生调试集成:公开 Node SDK Provider 用 Clang 编译 C 程序,经生产 macOS 会话、Core 准备、Registry 明确身份、生产 stdio 传输和 LLDB 命中源码断点、读取 main 栈帧、保留空格参数后继续并退出码 0;包 SHA-256/权限不变,所有本次进程回收。测试显式选择 SDK/工具,完整 UI、正式发行与 Windows 尚未验收,verification 保持 pending。 与显式 DAP 端点同时使用时,先按当前 Registry 身份校验请求能力,再调用端点工厂;launch/attach 与默认/明确身份的组合回归覆盖不会回退到本地适配器,配置编码与步进变换保留两个字段。", + "verification": "macOS:安装声明了 debugAdapters 的插件,在其语言文件上启动调试,确认适配器按声明的 stdio 传输由宿主拉起并命中断点;可执行文件缺失时显示插件设置里的安装提示而不是笼统错误;声明 tcpPort 或 tcpAnnounced 传输的适配器在启动进程前被拒绝并说明原因。声明 tcpLanguageServer 传输的适配器(调试服务随同一插件的语言服务器运行,如 Java Debug Server 之于 JDT LS)不由宿主拉起进程:宿主启动该插件声明的语言服务器,用 endpoint.command 向它要端口并连接 127.0.0.1,端口无效、语言服务器不属于该插件或不可用时会话以原因结束。从保存配置直接启动且未打开源码时也会激活、登记语言服务器并探测工具;停止调试取消端口命令而不停止共享语言服务器,拒绝工作区或会话替换后的旧端口。适配器只声明 attach,或 selectors 的 targetKinds 不含 file 时,不会被选作文件启动的调试器;Java 在迁入 Java Support 插件前仍使用宿主自带适配器。capabilities.disabled 中的能力在调试会话里不可用,并随 debug.createSession 传给 Core。会话进行中禁用、更新或卸载该插件,或在模块设置里停用它,只停止该插件拥有的会话,同语言其他插件的会话继续运行。启动后 app bundle 文件清单不变(scripts/macos/verify/verify-runtime-bundle-immutability.sh)。远程 attach 目前只有 Core 契约:attach 配置可声明 targetBindings.connection,Core 校验主机、端口与路径映射并按适配器自己的参数路径写入启动参数,非 localhost、127.0.0.0/8、::1 的端点返回 remote:true;macOS 宿主尚未接入 attach 与“附加到远程”对话框,因此本项暂不能在应用里验证远程附加,接入后补充:连接新的非回环端点前必须弹确认且默认不记住,附加结束只断开不结束远端程序。Windows:宿主尚未接入 debugAdapters,Core 校验通过不代表 Windows 能调试插件语言。 原生宿主路由回归:使用 shared/fixtures/debug/adapter-selection-v1.json 验证自动文件选择、明确身份、无源文件目标、空身份、已撤回身份和 launch/attach 不匹配;同语言多个适配器分别收到断点,重试保持身份,附加会话启动失败保留原会话。这里只验证宿主交接与受控会话,Provider 原生程序准备 RPC 有受控流程与真实 SDK 进程覆盖;真实适配器的完整工作流与 Windows 接入仍未验收。 真实原生调试集成:公开 Node SDK Provider 用 Clang 编译 C 程序,经生产 macOS 会话、Core 准备、Registry 明确身份、生产 stdio 传输和 LLDB 命中源码断点、读取 main 栈帧、保留空格参数后继续并退出码 0;包 SHA-256/权限不变,所有本次进程回收。测试显式选择 SDK/工具,完整 UI、正式发行与 Windows 尚未验收,verification 保持 pending。 与显式 DAP 端点同时使用时,先按当前 Registry 身份校验请求能力,再调用端点工厂;launch/attach 与默认/明确身份的组合回归覆盖不会回退到本地适配器,配置编码与步进变换保留两个字段。 日志验收:在最新macOS宿主分别启动、初始化、暂停/继续、正常结束、取消、超时和适配器拒绝,确认debug/的关键生命周期INFO、内部状态DEBUG、真实失败ERROR;启动与原生进程非零退出固定stage/exit_code进入debug,原始stderr、DAP正文、源码、变量、内存和程序输出不进入诊断日志。同一次launch失败不能重复记session失败,取消不记ERROR。共享Core组件验证不能替代macOS真实UI和Windows v2验收。", "notes": "普通 TCP 生命周期与用户显式 DAP 端点入口见 debug-tcp-transport;不放宽插件 tcpLanguageServer 回环约束,tcpPort/tcpAnnounced 托管模式仍拒绝。" }