From e4f6102f11be2755d5c6917b09f9fafee9a568dc Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Fri, 6 Nov 2026 23:18:14 +0800 Subject: [PATCH 1/4] diag(windows): observe backend stalls for issue 1338 --- .github/workflows/ci.yml | 8 + .github/workflows/release-tauri.yml | 29 +- docs/research/issue-1338-backend-hang.md | 77 ++ .../scripts/backend-hang-workflow.test.mjs | 53 ++ .../scripts/test-backend-hang-diagnostics.py | 43 ++ .../app/scripts/test-recorder-module.py | 1 + openless-all/app/src-tauri/Cargo.toml | 1 + .../backend-tests/tests/audio_processing.rs | 2 + .../app/src-tauri/src/commands/channels.rs | 10 +- .../app/src-tauri/src/commands/credentials.rs | 25 +- .../app/src-tauri/src/commands/dictation.rs | 4 +- .../src/commands/permissions_cmds.rs | 32 +- .../app/src-tauri/src/commands/settings.rs | 10 + .../app/src-tauri/src/issue1338_debug.rs | 712 ++++++++++++++++++ openless-all/app/src-tauri/src/lib.rs | 54 +- openless-all/app/src-tauri/src/permissions.rs | 32 +- .../src-tauri/src/persistence/credentials.rs | 114 +-- .../credentials/android_transfer.rs | 4 +- .../src-tauri/src/tauri_coordinator_host.rs | 82 +- 19 files changed, 1169 insertions(+), 124 deletions(-) create mode 100644 docs/research/issue-1338-backend-hang.md create mode 100644 openless-all/app/scripts/backend-hang-workflow.test.mjs create mode 100644 openless-all/app/scripts/test-backend-hang-diagnostics.py create mode 100644 openless-all/app/src-tauri/src/issue1338_debug.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1dfca882b..b0acdd3c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -815,6 +815,14 @@ jobs: timeout-minutes: 10 run: python scripts/test-windows-hang-diagnostics.py + - name: Compile opt-in backend observation host + timeout-minutes: 25 + run: cargo check --locked --manifest-path src-tauri/Cargo.toml --features issue-1338-diagnostics + + - name: Run isolated backend observation tests (off/on) + timeout-minutes: 10 + run: python scripts/test-backend-hang-diagnostics.py + - name: Run owner-thread target ownership tests timeout-minutes: 10 run: python scripts/test-windows-runtime-target.py diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 4a1312535..46aaa643c 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -40,6 +40,11 @@ on: type: boolean required: false default: false + windows_backend_diagnostics: + description: Opt-in issue 1338 backend observation build (branch only, not a fix) + type: boolean + required: false + default: false release_tag: description: Existing draft Stable tag to repair (admin only, Windows only) type: string @@ -80,10 +85,25 @@ jobs: # On workflow_dispatch / non-tag triggers github.ref_name is not a tag string, endsWith # returns false, and it falls back to stable — dispatch behavior unchanged. OPENLESS_HANG_DIAGNOSTICS: ${{ github.event_name == 'workflow_dispatch' && inputs.windows_hang_diagnostics && matrix.platform == 'windows-latest' && startsWith(github.ref, 'refs/heads/') && inputs.release_tag == '' && 'true' || 'false' }} + OPENLESS_BACKEND_DIAGNOSTICS: ${{ github.event_name == 'workflow_dispatch' && inputs.windows_backend_diagnostics && matrix.platform == 'windows-latest' && startsWith(github.ref, 'refs/heads/') && inputs.release_tag == '' && 'true' || 'false' }} OPENLESS_RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }} OPENLESS_RELEASE_IS_TAG: ${{ (startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-tauri')) || inputs.release_tag != '' }} OPENLESS_RELEASE_CHANNEL: ${{ (endsWith(inputs.release_tag || github.ref_name, '-beta-tauri') || contains(inputs.release_tag || github.ref_name, '-Beta.')) && 'beta' || 'stable' }} steps: + - name: Validate independent diagnostic sessions + if: github.event_name == 'workflow_dispatch' && inputs.windows_backend_diagnostics + shell: bash + env: + LEGACY_DIAGNOSTICS: ${{ inputs.windows_hang_diagnostics }} + INPUT_RELEASE_TAG: ${{ inputs.release_tag }} + INPUT_PLATFORM: ${{ inputs.platform }} + run: | + set -euo pipefail + [ "$LEGACY_DIAGNOSTICS" != true ] || { echo "::error::Choose one diagnostic session, not both"; exit 1; } + [ -z "$INPUT_RELEASE_TAG" ] || { echo "::error::Backend observations are branch-only, never release repairs"; exit 1; } + [[ "$GITHUB_REF" == refs/heads/* ]] || { echo "::error::Backend observations require a branch ref"; exit 1; } + [ "$INPUT_PLATFORM" != macos ] || { echo "::error::Backend observations require the Windows platform"; exit 1; } + - name: Validate targeted draft release repair if: inputs.release_tag != '' shell: bash @@ -352,6 +372,10 @@ jobs: if [ "$OPENLESS_HANG_DIAGNOSTICS" = true ]; then diagnostic_args=(--features issue-1266-diagnostics) fi + if [ "$OPENLESS_BACKEND_DIAGNOSTICS" = true ]; then + diagnostic_args=(--features issue-1338-diagnostics) + fi + echo "backend_observation=$OPENLESS_BACKEND_DIAGNOSTICS legacy_observation=$OPENLESS_HANG_DIAGNOSTICS source_sha=$OPENLESS_BUILD_ID" # WiX/MSI only accepts a numeric prerelease identifier. Manual # validation runs use the repository's Beta version even though the # ref is a branch (and therefore OPENLESS_RELEASE_CHANNEL=stable), @@ -583,7 +607,8 @@ jobs: platform = 'windows' protocol_revision = '0x4F4C0009' issue_1266_diagnostics = ($env:OPENLESS_HANG_DIAGNOSTICS -eq 'true') - diagnostic_session = $(if ($env:OPENLESS_HANG_DIAGNOSTICS -eq 'true') { 'issue-1266-2e2c7a47' } else { $null }) + issue_1338_diagnostics = ($env:OPENLESS_BACKEND_DIAGNOSTICS -eq 'true') + diagnostic_session = $(if ($env:OPENLESS_BACKEND_DIAGNOSTICS -eq 'true') { 'issue-1338-20261106' } elseif ($env:OPENLESS_HANG_DIAGNOSTICS -eq 'true') { 'issue-1266-2e2c7a47' } else { $null }) } | ConvertTo-Json -Compress | Set-Content -Encoding utf8NoBOM (Join-Path $manifestDir 'openless-provenance.json') # ── Collect artifacts ── @@ -692,7 +717,7 @@ jobs: if: matrix.platform == 'windows-latest' uses: actions/upload-artifact@v4 with: - name: ${{ env.OPENLESS_HANG_DIAGNOSTICS == 'true' && 'openless-windows-x64-issue-1266-diagnostics' || 'openless-windows-x64' }} + name: ${{ env.OPENLESS_BACKEND_DIAGNOSTICS == 'true' && 'openless-windows-x64-issue-1338-diagnostics' || env.OPENLESS_HANG_DIAGNOSTICS == 'true' && 'openless-windows-x64-issue-1266-diagnostics' || 'openless-windows-x64' }} path: | openless-all/app/src-tauri/target/release/bundle/nsis/*.exe openless-all/app/src-tauri/target/release/bundle/msi/*.msi diff --git a/docs/research/issue-1338-backend-hang.md b/docs/research/issue-1338-backend-hang.md new file mode 100644 index 000000000..83dcf0ac0 --- /dev/null +++ b/docs/research/issue-1338-backend-hang.md @@ -0,0 +1,77 @@ +# #1338 前端正常、后端相关操作卡住:观察轮次 A0 + +## 范围与证据资格 + +本轮基线为上游 `Open-Less/openless beta` 的 `c002b56c38d81bcd5044ddb22597a4e78ff2a88d`,不是用户现场安装提交。用户描述:快捷键不响应/不稳定,随后发现大模型配置无法加载、权限检测失败、托盘退出无效;前端界面仍正常。发现顺序不等于稳定触发步骤。现场 Windows 精确版本、OpenLess 安装版本、故障时刻和前后端进程存活情况未知。 + +只读输入是 `openless-2026-11-06T14-37-57.log`,累计多日、11995 行。原文件不更改、不上传全文;不把文件名当作故障时刻或安装版本。本轮不能以静态代码推断确认根因,因此只允许观察候选,不先改锁、调度、权限、超时、重试、退出或业务行为。 + +开发在 `D:/Dev/worktrees/openless/issue-1338-backend-hang`,分支 `fix/issue-1338-backend-hang`;不动原克隆。所有测试、安装依赖及构建仅在 Actions;提交前独立 `review-diff-bugs` 审查到 `no bug found`,仅推 fork,不提 PR、不发 release。 + +唯一会话日志预留为 `%LOCALAPPDATA%/OpenLess/diagnostics/openless-issue-1338-20261106-session.ndjson`,只追加、不覆盖、不轮转;调查过程不创建现场日志。A0 不是行为修复,不满足原症状验收。此后有效的单变量修复必须保留同一探针与会话文件,使用新的 attempt/change 身份。 + +## 正常行为模型与已有证据语义检查 + +| Contract | 期望及来源 | 可检查信号/边界 | 已有证据分类 | +|---|---|---|---| +| N1 | 用户触发快捷键应得到相应功能的可见结果(用户需求);合法禁用、取消和冲突单独区分 | 物理按键→监听→业务→可见结果;具体性能预算未定义,不编造秒数 | 用户反馈 VIOLATION;日志对本次请求关联 INSUFFICIENT_EVIDENCE | +| N2 | 打开模型配置应得到配置或明确失败,而非无结果(用户需求) | 请求、后端服务、响应、前端应用;不记录配置值 | 用户反馈 VIOLATION;日志无关联边界,INSUFFICIENT_EVIDENCE | +| N3 | 权限检测应返回真实状态或可辨认失败,不能把卡住当权限被拒绝(用户需求) | 后端检查进入/原生调用/返回;平台不适用是合法结果 | 用户反馈检测失败;具体错误类型和阶段 INSUFFICIENT_EVIDENCE | +| N4 | 用户主动托盘退出应能结束应用(用户需求) | 菜单回调、退出请求、清理、进程终止(外部观察) | 用户反馈 VIOLATION;日志无关联退出请求,INSUFFICIENT_EVIDENCE | +| N5 | 前端纯界面正常不等于后端健康;局部调用成功不等于后续全局健康 | 分别观察纯 UI、IPC、执行和可见结果,不用 heartbeat 替代业务进度 | 用户澄清前端正常;11983/11987 局部完成不能否定之后卡住 | + +状态版本、重复副作用和计数守恒不是当前已知症状,本轮不虚构该类契约。缺少经验证的日志覆盖、flush、请求对应关系和观测截止时刻,不能用缺少事件证明代码未执行。 + +有限日志线索(UTC): + +- L11570:08:57:23Z,选区润色快捷键注册失败且重试计数 5340。只说明该时段的注册异常;不是本次跨功能卡住的已证实起因。 +- L11983/L11987:14:29:42Z–43Z,有同一次语音流完成及插入 `PasteSent` 的后端局部记录;不证明用户界面内容正确,也不证明其后健康。 +- L11989:14:37:22Z 出现启动记录;不证明此前如何终止。 +- 其他时段 `sign_in_required` 不能视作本次系统权限检测失败。 + +即使删去所有 ERROR/WARN,用户报告的 N1/N2/N4 仍是行为异常;现有 INFO 不足以确认其因果边界。当前没有能稳定复现原故障的红色反馈环。 + +## 初始可证伪假设(静态线索不是结论) + +以下是排名初始集,接缝需以实际实现和最终探针表校准。每个假设当前均为 `INCONCLUSIVE`,尚无 `ACTIONABLE` 行为修复资格。 + +| ID | Hypothesis | Falsifiable prediction | If true, what change would make the bug disappear? | Probe IDs | +|---|---|---|---|---| +| H1 | 同步 `get_settings` 的 preferences 读取或 warm-capture configure/stop 阶段阻塞了原生调用派发,使后续读取及托盘事件不能继续(N2/N4/N5) | 同一生成线程的同步配置命令在 preferences 或 warm configure 阶段进入后未返回,其他原生主线程任务未执行;需结合完整覆盖及现场线程栈排除捕获损失 | 只将证实阻塞的该同步操作移出主线程且保持结果/顺序语义,原跨功能停滞应消失 | P1/P2/P4/P6 | +| H2 | 权限原生麦克风检查在异步 worker 内同步阻塞,导致其他异步服务没有执行机会(N2/N3/N5) | 原生 mic 阶段进入后停住,异步命令已派发但执行/完成停滞,而原生事件循环仍可执行;必须结合线程栈/执行资源观察区分孤立慢调用 | 只把证实阻塞的 mic 检查迁至合适的 blocking 执行边界,其他请求应恢复且权限语义不变 | P1/P2/P5/P6 | +| H3 | 凭据共享锁持有路径停滞,配置读取等待该锁,并可能占住调用执行资源(N2/N5) | 生成锁操作显示已持有而其他操作等待;命令与该锁必须有真实父子/同线程调用关联;原生权限可独立返回时可否定全局卡住归因 | 只缩短已证实持锁路径或修正已证实锁环,保留凭据授权/缓存/写入原子性,配置读取与其他请求恢复 | P2/P3 | +| H4 | 后端任务在原生主线程派发边界等待,托盘事件也不能进入;不是前端渲染冻结(N1/N4/N5) | 真实主线程排队已接受但执行边界停滞,现场栈显示该队列被占用;原生事件回调/退出进入边界缺失只有覆盖完整时才可用 | 只移除已证实的主线程阻塞或同步重入等待,保持窗口资源所有权与业务取消语义 | P1/P4/P6 | +| H5 | 同一前端页面缓存的 startup readiness 未完成,普通命令在前端共同前置阶段等待,尚未真正发送(N2/N3/N5) | 原生 `get_startup_snapshot` 已开始而未完成,现场前端 ready Promise 仍 pending;需要前端现场检查,不把后端缺少命令入口等同于未发送;若同一页面 ready 已 fulfilled,则不能用后来挂起解释它再次 pending | 只修正证据定位的 startup 阶段阻塞,保持恢复/凭据与生命周期安全边界,普通调用应开始发送并返回 | P1/P2(前端状态覆盖未知,须另取现场) | + +源代码核对:`src/lib/ipc/shared.ts:48–63,77–95` 将普通调用放在缓存的 readiness 后;`src-tauri/src/commands/dictation.rs` 的 startup 命令调用 `crates/openless-core/src/api.rs:4967–4997` 的 `start()`,包含 lifecycle gate、偏好、凭据预热与恢复,而非纯存活检查。`commands/settings.rs:24–28,242–256` 的同步设置读取还会调用 warm configure;`recorder.rs:1353–1360,1388,1405` 涉及 lifecycle gate 与 stop。`core_adapters.rs:1642–1663` 的权限 future 内含同步原生调用;`permissions.rs:441–442` 的 Windows accessibility 是 NotApplicable,而 `:451–471` 的麦克风会探测设备/建流。权限前端 `Promise.all` 可因一项悬挂而整组不更新,因此不能仅凭权限 UI 推断所有 worker 饥饿。以上均为基线源码契约,不是运行时因果确认。 + +## 探针约束 + +最多六个真实边界:P1 原生 invoke 派发;P2 命令执行/服务等待;P3 凭据锁等待/持有;P4 到原生主线程队列的真实排队/执行;P5 权限原生检查;P6 托盘退出/应用生命周期。最终实现必须标明支持的命令、线程/父子标签和未覆盖部分。 + +仅后端观察不覆盖前端请求意图、前端发送失败、页面生命周期或响应应用结果。不得把后端没收到当作前端没发送,也不得把 dispatch 返回当作异步业务完成。日志导出不依赖挂住的 IPC:使用文件系统取得唯一会话文件;若当前 writer 健康/尾部 flush 不可观察,明确为覆盖未知。没有前端诊断 transport,不能虚报前端丢失计数为零。 + +探针必须默认关闭、仅显式分支诊断 Windows 包启用;所有事实只用闭合枚举、布尔值、计数、生成的运行内操作/线程标签和单调时长。不得含命令参数、模型/渠道/设备/窗口标识、路径、内容、凭据或原始错误。生产热路径不做序列化、磁盘写入、同步等待或新增延迟/重试;队列必须有界且损失可辨认,不宣称 lock-free。取消/unwind 仅算 scope-left,不能当成功。 + +## A0 实际实现与覆盖边界 + +- Cargo feature `issue-1338-diagnostics` 默认关闭,Windows 显式启用后才初始化真实 writer;不是旧 `issue-1266-diagnostics` 会话。 +- P1 仅 allowlist:`get_startup_snapshot`、`get_settings`、`list_channels`、`read_credential`、两项权限检测。handler 的 bool 只表示派发/处理返回,不是 async 完成;其他 IPC 无覆盖。 +- P2 观察以上命令的服务执行,设置读取区分 PrefsRead/WarmConfigure;凭据 blocking worker 使用显式生成的 operation。startup 仅命令整体,不覆盖 Core 内部每个恢复子阶段;`get_settings_snapshot` 当前不在覆盖内。 +- P3 包装实际凭据总锁和 cache 锁,释放记录在真实 unlock 后;不改变锁顺序/生命周期或凭据内容。其他锁没有被凭据锁探针覆盖。 +- P4 host/capsule 主线程包装区分 requested、dispatch_returned、entered、returned/scope_left;requested 可能早于 handle 获取,不能仅凭 requested 认定成功排队。 +- P5 观察导出的原生权限检查进入/返回和闭合 PermissionStatus;不观察所有平台内部 helper,也不细分底层设备/建流/play/drop 的每一步。要进一步区分底层卡点须结合现场栈,而非凭单一进入记录猜测。 +- P6 观察 tray quit、request_quit 和既有 Ready/Close/Destroyed/ExitRequested/Exit 回调。保留上游已有 3 秒强退兜底;没有增加产品延迟。Exit callback 本身不证明进程终止。 +- async 观察只在每次 poll 的同步栈安装/恢复 TLS,不跨 await 维持线程上下文。缺少真实父关联显示 unknown,不以邻近记录补关联。 +- writer 容量 512、`try_send`,各记录携带健康快照;`tail_capture=unknown` 始终保留,flush 为 OS 写入而非 fsync。现场可能有最终未落盘记录或初始化/磁盘失败;过去的零丢失不是完整覆盖证明。 +- 可选 `issue1338_health` 仅诊断 Windows 包注册,独立读取原子状态;仍依赖 IPC,卡住时不保证可用。直接从文件系统取得会话文件,不以普通应用日志导出代替。 + +### 分支构建与交付约束 + +`Release Tauri (cross-platform)` 手动指定当前分支、`platform=all`、`windows_backend_diagnostics=true`、`windows_hang_diagnostics=false`,`release_tag` 留空。两种诊断会话互斥。Windows artifact 名为 `openless-windows-x64-issue-1338-diagnostics`,包含 source provenance 与匹配 EXE/PDB 验证清单;macOS 是普通构建。CI 显式编译 feature-on 宿主并运行导入真实模块的 off/on 隔离测试;这些测试不打开现场会话文件。测试通过只证明诊断层契约,不证明原设备问题修复。 + +同时为准确 head 运行分支 CI(含 Linux 包门禁)及 Android 全 ABI 分支构建,不创建 PR/tag/release。验证 run head、实际 job、非空未过期 artifact 后才交付;artifact archive digest 不等于内部安装文件的 checksum。 + +## 验收与下一步 + +A0 先接受诊断本身的安全性、边界覆盖、默认关闭和 Actions 产物来源;不宣告修复。有效行为修复须取得对应现场/探针证据,选一个可逆因果变量,验证同一 N1–N5 用户可见结果及退出结果,再经用户确认后清理探针。两次不能重现并不自动证明有效。 diff --git a/openless-all/app/scripts/backend-hang-workflow.test.mjs b/openless-all/app/scripts/backend-hang-workflow.test.mjs new file mode 100644 index 000000000..3c281d9dc --- /dev/null +++ b/openless-all/app/scripts/backend-hang-workflow.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import yaml from 'js-yaml'; + +const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8').replace(/\r/g, ''); +const release = yaml.load(read('../../../.github/workflows/release-tauri.yml')); +const ci = yaml.load(read('../../../.github/workflows/ci.yml')); +const inputs = release.on.workflow_dispatch.inputs; +assert.equal(inputs.windows_backend_diagnostics.default, false); +assert.equal(inputs.windows_hang_diagnostics.default, false); +const gate = release.jobs.build.env.OPENLESS_BACKEND_DIAGNOSTICS; +for (const fragment of [ + 'workflow_dispatch', + 'inputs.windows_backend_diagnostics', + 'windows-latest', + 'refs/heads/', + "inputs.release_tag == ''", +]) { + assert.ok(gate.includes(fragment)); +} +const steps = release.jobs.build.steps; +const guard = steps.find((step) => step.name === 'Validate independent diagnostic sessions'); +assert.ok(guard); +assert.ok(steps.indexOf(guard) < steps.findIndex((step) => step.name === 'Build (Windows)')); +assert.match(guard.run, /LEGACY_DIAGNOSTICS.*!= true/); +assert.match(guard.run, /-z "\$INPUT_RELEASE_TAG"/); +assert.match(guard.run, /refs\/heads\/\*/); +assert.match(guard.run, /INPUT_PLATFORM.*!= macos/); +assert.equal( + steps.find((step) => step.uses === 'actions/checkout@v4').with.ref, + '${{ inputs.release_tag || github.sha }}', +); +const build = steps.find((step) => step.name === 'Build (Windows)').run; +assert.match(build, /diagnostic_args=\(--features issue-1338-diagnostics\)/); +assert.match(build, /backend_observation=\$OPENLESS_BACKEND_DIAGNOSTICS/); +assert.equal((build.match(/build "\$\{diagnostic_args\[@\]\}" --bundles/g) ?? []).length, 4); +const provenance = steps.find((step) => step.name === 'Write Windows artifact provenance').run; +assert.match(provenance, /issue_1338_diagnostics = \(\$env:OPENLESS_BACKEND_DIAGNOSTICS -eq 'true'\)/); +assert.match(provenance, /issue-1338-20261106/); +const upload = steps.find((step) => step.name === 'Upload Windows artifacts'); +assert.match(upload.with.name, /openless-windows-x64-issue-1338-diagnostics/); +assert.match(upload.with.path, /openless-provenance\.json/); +assert.match(upload.with.path, /openless-symbol-pair\.json/); +// Both observation modes remain branch-artifact paths, never publishing conditions. +assert.doesNotMatch(release.jobs.publish.if, /diagnostics/); +assert.match(release.jobs.publish.if, /refs\/tags\/v/); +assert.match(release.jobs.publish.if, /inputs.release_tag != ''/); +const ciSteps = Object.values(ci.jobs).flatMap((job) => job.steps ?? []); +assert.ok( + ciSteps.some((step) => /cargo check.*--features issue-1338-diagnostics/.test(step.run ?? '')), +); +assert.ok(ciSteps.some((step) => /test-backend-hang-diagnostics\.py/.test(step.run ?? ''))); +console.log('backend observation branch-build workflow contracts passed'); diff --git a/openless-all/app/scripts/test-backend-hang-diagnostics.py b/openless-all/app/scripts/test-backend-hang-diagnostics.py new file mode 100644 index 000000000..889252374 --- /dev/null +++ b/openless-all/app/scripts/test-backend-hang-diagnostics.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Actions-only #1338 harness importing the production module, without Tauri/native DLLs. + +The real Windows writer is compiled by the feature-on host check. Unit tests use +injected memory/failing writers, never the user's session file. The facade binary +also executes the non-test, disabled path (including feature-on macOS). +""" +import json +from pathlib import Path +import subprocess +import tempfile + +source = Path(__file__).resolve().parents[1] / "src-tauri/src/issue1338_debug.rs" +with tempfile.TemporaryDirectory(prefix="openless-backend-hang-tests-") as directory: + root = Path(directory) + (root / "src/bin").mkdir(parents=True) + (root / "Cargo.toml").write_text('''[package] +name = "openless-backend-hang-tests" +version = "0.1.0" +edition = "2021" +[features] +issue-1338-diagnostics = [] +[dependencies] +parking_lot = "0.12" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +''', encoding="utf-8") + module = "#[path = " + json.dumps(str(source), ensure_ascii=False) + "]\nmod issue1338_debug;\n" + (root / "src/lib.rs").write_text(module, encoding="utf-8") + (root / "src/bin/facade.rs").write_text(module + '''fn main() { + if !issue1338_debug::enabled() { + issue1338_debug::initialize(); + let health = serde_json::to_value(issue1338_debug::health()).unwrap(); + assert_eq!(health["initialized"], false); + assert_eq!(health["ready"], false); + let op = issue1338_debug::Operation::new(issue1338_debug::Probe::P1); + op.mark(issue1338_debug::Event::Requested); + } +} +''', encoding="utf-8") + for features in ([], ["--features", "issue-1338-diagnostics"]): + for command in (["test", "--lib"], ["run", "--bin", "facade"]): + subprocess.run(["cargo", *command, "--manifest-path", str(root / "Cargo.toml"), *features], check=True) diff --git a/openless-all/app/scripts/test-recorder-module.py b/openless-all/app/scripts/test-recorder-module.py index 62bd56df0..749adedb0 100644 --- a/openless-all/app/scripts/test-recorder-module.py +++ b/openless-all/app/scripts/test-recorder-module.py @@ -51,6 +51,7 @@ "pub use audio::{pcm_f32_to_i16_le, AudioProcessingConfig, AudioProcessor, FarEndQueue, ReferenceAudioBuffer};\n" "pub trait AudioConsumer: Send + Sync { fn consume_pcm_chunk(&self, pcm: &[u8]); }\n" + "#[path = " + json.dumps(str(recorder.with_name("issue1266_debug.rs")), ensure_ascii=False) + "]\nmod issue1266_debug;\n" + + "#[path = " + json.dumps(str(recorder.with_name("issue1338_debug.rs")), ensure_ascii=False) + "]\nmod issue1338_debug;\n" + "#[path = " + json.dumps(str(recorder), ensure_ascii=False) + "]\npub mod recorder;\n" + "#[path = " + json.dumps(str(recorder.with_name("native_audio_cue.rs")), ensure_ascii=False) + "]\npub mod native_audio_cue;\n" ) diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 7b52de55a..a2b5f2030 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -189,6 +189,7 @@ window-vibrancy = "0.7" default = ["custom-protocol"] custom-protocol = ["tauri/custom-protocol"] issue-1266-diagnostics = [] +issue-1338-diagnostics = [] [profile.release] # 之前没有 [profile.release] —— 发布版走 Cargo 默认(无 LTO、不 strip、 diff --git a/openless-all/app/src-tauri/backend-tests/tests/audio_processing.rs b/openless-all/app/src-tauri/backend-tests/tests/audio_processing.rs index 213a9da86..3e1b5e32f 100644 --- a/openless-all/app/src-tauri/backend-tests/tests/audio_processing.rs +++ b/openless-all/app/src-tauri/backend-tests/tests/audio_processing.rs @@ -2,5 +2,7 @@ #![allow(dead_code)] #[path = "../../src/issue1266_debug.rs"] mod issue1266_debug; +#[path = "../../src/issue1338_debug.rs"] +mod issue1338_debug; #[path = "../../src/recorder.rs"] mod recorder; diff --git a/openless-all/app/src-tauri/src/commands/channels.rs b/openless-all/app/src-tauri/src/commands/channels.rs index b927aed5c..da193d5bb 100644 --- a/openless-all/app/src-tauri/src/commands/channels.rs +++ b/openless-all/app/src-tauri/src/commands/channels.rs @@ -23,10 +23,12 @@ pub async fn list_channels( window: Window, kind: String, ) -> Result, String> { - ensure_main_window(&window)?; - core.list_channels(parse_kind(&kind)?) - .await - .map_err(|error| error.to_string()) + crate::issue1338_debug::observe_future(crate::issue1338_debug::Kind::ListChannels, async move { + ensure_main_window(&window)?; + core.list_channels(parse_kind(&kind)?) + .await + .map_err(|error| error.to_string()) + }).await } #[tauri::command] diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index 2d073e7b9..6ada12f91 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -318,8 +318,19 @@ impl openless_core::CredentialStore for SystemCredentialStore { fn run_credential_task( task: impl FnOnce() -> Result + Send + 'static, ) -> futures_util::future::BoxFuture<'static, Result> { + let operation = crate::issue1338_debug::Operation::new(crate::issue1338_debug::Probe::P2); Box::pin(async move { - tauri::async_runtime::spawn_blocking(task) + operation.record(crate::issue1338_debug::Event::Requested, crate::issue1338_debug::Outcome::Observed, + crate::issue1338_debug::Facts { kind: crate::issue1338_debug::Kind::CredentialWorker, ..Default::default() }); + tauri::async_runtime::spawn_blocking(move || { + use crate::issue1338_debug::{Boundary, Kind, SyncContext}; + let _context = SyncContext::enter(operation); + let boundary = Boundary { operation, kind: Kind::CredentialWorker }; + boundary.record(crate::issue1338_debug::Event::Entered, crate::issue1338_debug::Outcome::Observed); + let result = task(); + boundary.returned(); + result + }) .await .map_err(|_| { openless_core::BackendError::new( @@ -906,11 +917,13 @@ pub async fn read_credential( account: String, provider: Option, ) -> Result, String> { - ensure_main_window(&window)?; - core.read_credential(credential_key(&account, provider)?) - .await - .map(|value| value.map(openless_core::SecretValue::into_exposed)) - .map_err(|error| error.to_string()) + crate::issue1338_debug::observe_future(crate::issue1338_debug::Kind::ReadCredential, async move { + ensure_main_window(&window)?; + core.read_credential(credential_key(&account, provider)?) + .await + .map(|value| value.map(openless_core::SecretValue::into_exposed)) + .map_err(|error| error.to_string()) + }).await } fn credential_key( diff --git a/openless-all/app/src-tauri/src/commands/dictation.rs b/openless-all/app/src-tauri/src/commands/dictation.rs index d0f11b05f..21f4ca793 100644 --- a/openless-all/app/src-tauri/src/commands/dictation.rs +++ b/openless-all/app/src-tauri/src/commands/dictation.rs @@ -4,7 +4,9 @@ use super::*; pub async fn get_startup_snapshot( core: CoreState<'_>, ) -> Result { - core.start().await.map_err(|error| error.to_string()) + crate::issue1338_debug::observe_future(crate::issue1338_debug::Kind::StartupSnapshot, async move { + core.start().await.map_err(|error| error.to_string()) + }).await } #[cfg(not(mobile))] diff --git a/openless-all/app/src-tauri/src/commands/permissions_cmds.rs b/openless-all/app/src-tauri/src/commands/permissions_cmds.rs index 1b852af5e..0fca08dce 100644 --- a/openless-all/app/src-tauri/src/commands/permissions_cmds.rs +++ b/openless-all/app/src-tauri/src/commands/permissions_cmds.rs @@ -97,13 +97,15 @@ pub fn open_external_url(url: String) -> Result<(), String> { pub async fn check_accessibility_permission( core: CoreState<'_>, ) -> Result { - Ok(core - .services() - .platform - .accessibility_permission() - .await - .map(|snapshot| map_core_permission(snapshot.accessibility)) - .unwrap_or(PermissionStatus::NotApplicable)) + crate::issue1338_debug::observe_future(crate::issue1338_debug::Kind::Accessibility, async move { + Ok(core + .services() + .platform + .accessibility_permission() + .await + .map(|snapshot| map_core_permission(snapshot.accessibility)) + .unwrap_or(PermissionStatus::NotApplicable)) + }).await } #[tauri::command] @@ -130,13 +132,15 @@ pub async fn request_accessibility_permission( #[tauri::command] pub async fn check_microphone_permission(core: CoreState<'_>) -> Result { - Ok(core - .services() - .platform - .microphone_permission() - .await - .map(|snapshot| map_core_permission(snapshot.microphone)) - .unwrap_or(PermissionStatus::NotApplicable)) + crate::issue1338_debug::observe_future(crate::issue1338_debug::Kind::Microphone, async move { + Ok(core + .services() + .platform + .microphone_permission() + .await + .map(|snapshot| map_core_permission(snapshot.microphone)) + .unwrap_or(PermissionStatus::NotApplicable)) + }).await } #[tauri::command] diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index bf1991cdc..69c1aa681 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -22,9 +22,19 @@ pub fn set_android_audio_monitoring(enabled: bool) { #[tauri::command] pub fn get_settings(core: CoreState<'_>) -> UserPreferences { + use crate::issue1338_debug::{Boundary, Kind, Probe, SyncContext}; + let command = Boundary::enter(Probe::P2, Kind::GetSettings); + let _context = SyncContext::enter(command.operation); + let read = Boundary::enter(Probe::P2, Kind::PrefsRead); let mut prefs = core.get_preferences(); + read.returned(); + drop(read); prefs.update_channel = effective_update_channel(None, &prefs, env!("CARGO_PKG_VERSION")); + let configure = Boundary::enter(Probe::P2, Kind::WarmConfigure); sync_low_latency_capture_preferences(&prefs); + configure.returned(); + drop(configure); + command.returned(); prefs } diff --git a/openless-all/app/src-tauri/src/issue1338_debug.rs b/openless-all/app/src-tauri/src/issue1338_debug.rs new file mode 100644 index 000000000..7566a45d7 --- /dev/null +++ b/openless-all/app/src-tauri/src/issue1338_debug.rs @@ -0,0 +1,712 @@ +//! [DEBUG-ISSUE-1338] A0 observations only, not a behavioral fix. +//! Fixed closed facts only. Producers never serialize, write, initialize a sink, +//! acquire application locks, or wait for queue capacity. try_send is not lock-free. +//! Missing records and the final flush tail remain unknown, including forced exit. +//! Backend-only: no frontend intent/send/response-application coverage. A null +//! parent is unknown; no IDs are inferred from command names or thread proximity. +//! P4 Requested begins the wrapper (possibly before handle acquisition); only +//! DispatchReturned reports dispatch acceptance, never execution completion. +//! P5 covers exported native checks, not every internal platform helper. +#![allow(dead_code)] +use serde::Serialize; +pub(crate) const ATTEMPT: &str = "A0-observation"; +pub(crate) const CHANGE: &str = "no-behavior-change"; +pub(crate) const SESSION: &str = "issue-1338-20261106"; +pub(crate) const LOG_NAME: &str = "openless-issue-1338-20261106-session.ndjson"; +pub(crate) const fn enabled() -> bool { + cfg!(all(target_os = "windows", feature = "issue-1338-diagnostics")) +} +#[derive(Clone, Copy, Debug, Serialize)] +pub(crate) enum Probe { P1, P2, P3, P4, P5, P6 } +impl Probe { + fn hypotheses(self) -> &'static [&'static str] { + match self { + Self::P1 | Self::P4 | Self::P6 => &["H1", "H2", "H4", "H5"], + Self::P2 => &["H1", "H2", "H3", "H5"], Self::P3 => &["H3"], Self::P5 => &["H2"], + } + } +} +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Event { + Entered, Returned, ScopeLeft, Requested, Acquired, Released, DispatchReturned, + Ready, ExitRequested, Exit, CloseRequested, Destroyed, +} +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Outcome { + Observed, Returned, Ok, Error, Granted, Denied, NotDetermined, Restricted, + NotApplicable, NoDevice, +} +#[derive(Clone, Copy, Debug, Default, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Kind { + #[default] None, + GetSettings, ListChannels, ReadCredential, Accessibility, Microphone, + StartupSnapshot, PrefsRead, WarmConfigure, CredentialWorker, + CredentialsLock, CacheLock, HostDispatch, CapsuleDispatch, TrayQuit, QuitRequest, +} +impl Kind { + // Never pass through arbitrary command names, arguments, labels or payloads. + pub(crate) fn command(command: &str) -> Option { + match command { + "get_settings" => Some(Self::GetSettings), + "get_startup_snapshot" => Some(Self::StartupSnapshot), + "list_channels" => Some(Self::ListChannels), + "read_credential" => Some(Self::ReadCredential), + "check_accessibility_permission" => Some(Self::Accessibility), + "check_microphone_permission" => Some(Self::Microphone), + _ => None, + } + } +} +#[derive(Clone, Copy, Debug, Default, Serialize)] +pub(crate) struct Facts { pub kind: Kind, pub value: u64, pub flag: bool } +#[derive(Clone, Copy, Debug)] +pub(crate) struct Operation { id: u64, parent: u64, probe: Probe } +impl Operation { + pub(crate) fn new(probe: Probe) -> Self { Self::child(probe, current()) } + pub(crate) fn child(probe: Probe, parent: Option) -> Self { + #[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] + let id = sink::NEXT_OPERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + #[cfg(not(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics"))))] + let id = 0; + Self { id, parent: parent.map_or(0, |p| p.id), probe } + } + pub(crate) fn record(self, event: Event, outcome: Outcome, facts: Facts) { + #[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] + sink::record(self, event, outcome, facts); + #[cfg(not(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics"))))] + let _ = (event, outcome, facts); + } + pub(crate) fn mark(self, event: Event) { self.record(event, Outcome::Observed, Facts::default()); } +} +#[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] +thread_local! { + static CURRENT: std::cell::Cell> = const { std::cell::Cell::new(None) }; + static THREAD: u64 = NEXT_THREAD.fetch_add(1, std::sync::atomic::Ordering::Relaxed); +} +#[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] +static NEXT_THREAD: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); +fn current() -> Option { + #[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] + { CURRENT.with(std::cell::Cell::get) } + #[cfg(not(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics"))))] + { None } +} +#[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] +fn thread_label() -> u64 { THREAD.with(|label| *label) } +/// Only install inside a real synchronous stack; !Send, never held across await. +pub(crate) struct SyncContext { + previous: Option, + _not_send: std::marker::PhantomData>, +} +impl SyncContext { + pub(crate) fn enter(operation: Operation) -> Self { + #[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] + let previous = CURRENT.with(|slot| slot.replace(Some(operation))); + #[cfg(not(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics"))))] + let previous = None; + Self { previous, _not_send: std::marker::PhantomData } + } +} +impl Drop for SyncContext { + fn drop(&mut self) { + #[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] + CURRENT.with(|slot| slot.set(self.previous)); + } +} +/// Owns no TLS context. Safe to move with an async future; cancellation is scope_left. +pub(crate) struct Boundary { pub operation: Operation, pub kind: Kind } +impl Boundary { + pub(crate) fn enter(probe: Probe, kind: Kind) -> Self { + let boundary = Self { operation: Operation::new(probe), kind }; + boundary.record(Event::Entered, Outcome::Observed); boundary + } + pub(crate) fn record(&self, event: Event, outcome: Outcome) { + self.operation.record(event, outcome, Facts { kind: self.kind, ..Default::default() }); + } + pub(crate) fn returned(&self) { self.record(Event::Returned, Outcome::Returned); } +} +impl Drop for Boundary { fn drop(&mut self) { self.record(Event::ScopeLeft, Outcome::Observed); } } +/// An async command's operation has no invented dispatch parent. Only its +/// individual poll stack is a real synchronous parent for services invoked there. +/// This wrapper pins in place and restores TLS before every Pending/Ready/unwind. +pub(crate) async fn observe_future(kind: Kind, future: impl std::future::Future) -> T { + let boundary = Boundary::enter(Probe::P2, kind); + let mut future = std::pin::pin!(future); + let result = std::future::poll_fn(|cx| { + let _context = SyncContext::enter(boundary.operation); + std::future::Future::poll(future.as_mut(), cx) + }).await; + boundary.returned(); + result +} + +#[derive(Clone, Copy)] +pub(crate) struct MainDispatch { operation: Operation, kind: Kind } +impl MainDispatch { + pub(crate) fn new(kind: Kind) -> Self { + let this = Self { operation: Operation::new(Probe::P4), kind }; + this.record(Event::Requested, Outcome::Observed); this + } + fn record(self, event: Event, outcome: Outcome) { + self.operation.record(event, outcome, Facts { kind: self.kind, ..Default::default() }); + } + pub(crate) fn wrap(self, task: impl FnOnce() + Send + 'static) -> impl FnOnce() + Send + 'static { + move || { + let _context = SyncContext::enter(self.operation); + let boundary = Boundary { operation: self.operation, kind: self.kind }; + boundary.record(Event::Entered, Outcome::Observed); + task(); boundary.returned(); + } + } + // Acceptance/inline return only, NOT closure execution or async completion. + pub(crate) fn dispatched(self, accepted: bool) { + self.record(Event::DispatchReturned, if accepted { Outcome::Ok } else { Outcome::Error }); + } +} +#[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] +pub(crate) struct Guard<'a, T: ?Sized> { + guard: Option>, boundary: Boundary, +} +#[cfg(not(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics"))))] +pub(crate) type Guard<'a, T> = parking_lot::MutexGuard<'a, T>; +pub(crate) fn lock(mutex: &parking_lot::Mutex, kind: Kind) -> Guard<'_, T> { + #[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] + { + let boundary = Boundary { operation: Operation::new(Probe::P3), kind }; + boundary.record(Event::Requested, Outcome::Observed); + let guard = mutex.lock(); + boundary.record(Event::Acquired, Outcome::Observed); + Guard { guard: Some(guard), boundary } + } + #[cfg(not(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics"))))] + { let _ = kind; mutex.lock() } +} +#[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] +impl std::ops::Deref for Guard<'_, T> { + type Target = T; + fn deref(&self) -> &T { self.guard.as_deref().expect("guard alive") } +} +#[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] +impl std::ops::DerefMut for Guard<'_, T> { + fn deref_mut(&mut self) -> &mut T { self.guard.as_deref_mut().expect("guard alive") } +} +#[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] +impl Drop for Guard<'_, T> { + fn drop(&mut self) { + drop(self.guard.take()); // Emit after the REAL unlock, never before it. + self.boundary.record(Event::Released, Outcome::Observed); + } +} +pub(crate) fn initialize() { + #[cfg(all(target_os = "windows", feature = "issue-1338-diagnostics", not(test)))] + sink::initialize(); +} +pub(crate) fn build_id() -> &'static str { + let value = option_env!("OPENLESS_BUILD_ID").unwrap_or("unbound"); + if value.len() == 40 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) { value } + else { "unbound" } +} + +#[derive(Default, Serialize)] +pub(crate) struct Health { + enabled: bool, + build_id: &'static str, + run_id: Option, + initialized: bool, + ready: bool, + dropped: u64, + queue_closed: u64, + spawn_failed: bool, + io_failed: bool, + written: u64, + flushed: u64, +} + +/// Independent counters, not a record through the possibly failed channel. +/// A snapshot is not transactional; flush is an OS write, not durable fsync. +pub(crate) fn health() -> Health { + #[allow(unused_mut)] + let mut health = Health { enabled: enabled(), build_id: build_id(), ..Health::default() }; + #[cfg(all(target_os = "windows", feature = "issue-1338-diagnostics"))] + if let Some(sink) = sink::SINK.get() { + sink.health.snapshot(&mut health); + health.run_id = Some(format!("r{:x}", sink.run)); + } + health +} + +#[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] +mod sink { + use super::*; + use std::io::{self, Write}; + use std::sync::atomic::{AtomicBool, AtomicU64, Ordering::Relaxed}; + use std::sync::mpsc::{sync_channel, Receiver, SyncSender, TrySendError}; + use std::sync::{Arc, OnceLock}; + use std::time::{Instant, SystemTime, UNIX_EPOCH}; + + pub(super) static NEXT_OPERATION: AtomicU64 = AtomicU64::new(1); + // One source sequence per probe, not a global clock or cross-probe causality. + static SEQUENCES: [AtomicU64; 6] = [const { AtomicU64::new(0) }; 6]; + pub(super) static SINK: OnceLock = OnceLock::new(); + + #[derive(Default)] + pub(super) struct HealthState { + initialized: AtomicBool, + ready: AtomicBool, + dropped: AtomicU64, + queue_closed: AtomicU64, + spawn_failed: AtomicBool, + io_failed: AtomicBool, + written: AtomicU64, + flushed: AtomicU64, + } + impl HealthState { + fn snapshot_value(&self) -> Health { + let mut health = Health { enabled: enabled(), build_id: build_id(), ..Default::default() }; + self.snapshot(&mut health); + health + } + pub(super) fn snapshot(&self, health: &mut Health) { + health.initialized = self.initialized.load(Relaxed); + health.ready = self.ready.load(Relaxed); + health.dropped = self.dropped.load(Relaxed); + health.queue_closed = self.queue_closed.load(Relaxed); + health.spawn_failed = self.spawn_failed.load(Relaxed); + health.io_failed = self.io_failed.load(Relaxed); + health.written = self.written.load(Relaxed); + health.flushed = self.flushed.load(Relaxed); + } + } + + #[derive(Clone, Copy, Debug)] + struct Record { + operation: Operation, + event: Event, + outcome: Outcome, + facts: Facts, + seq: u64, + elapsed_us: u64, + thread_label: u64, + } + + pub(super) struct Sink { + sender: SyncSender, + pub(super) health: Arc, + started: Instant, + pub(super) run: u128, + } + + #[cfg(test)] + thread_local! { + static INSPECT: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; + } + impl Sink { + fn submit(&self, record: Record) { + #[cfg(test)] + INSPECT.with(|hook| { if let Some(inspect) = hook.borrow().as_ref() { inspect(&record); } }); + match self.sender.try_send(record) { + Ok(()) => {}, + Err(TrySendError::Full(_)) => { self.health.dropped.fetch_add(1, Relaxed); }, + Err(TrySendError::Disconnected(_)) => { + self.health.dropped.fetch_add(1, Relaxed); + self.health.queue_closed.fetch_add(1, Relaxed); + }, + } + } + } + + fn submit(sink: &Sink, operation: Operation, event: Event, outcome: Outcome, facts: Facts) { + sink.submit(Record { + operation, event, outcome, facts, + seq: SEQUENCES[operation.probe as usize].fetch_add(1, Relaxed), + elapsed_us: sink.started.elapsed().as_micros().min(u64::MAX as u128) as u64, + thread_label: thread_label(), + }); + } + + #[cfg(test)] + thread_local! { + static TEST_SINK: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; + } + + pub(super) fn record(operation: Operation, event: Event, outcome: Outcome, facts: Facts) { + // No lazy spawn, channel allocation or file initialization in a probe. + #[cfg(test)] + if TEST_SINK.with(|slot| { + if let Some(sink) = slot.borrow().as_ref() { + submit(sink, operation, event, outcome, facts); + true + } else { false } + }) { return; } + if let Some(sink) = SINK.get() { submit(sink, operation, event, outcome, facts); } + } + + #[cfg(test)] + pub(super) fn capture(action: impl FnOnce()) -> Vec { + let (sender, receiver) = sync_channel(512); + let health = Arc::new(HealthState::default()); + let sink = Sink { sender, health: Arc::clone(&health), started: Instant::now(), run: 1 }; + TEST_SINK.with(|slot| assert!(slot.borrow_mut().replace(sink).is_none())); + struct Reset; + impl Drop for Reset { + fn drop(&mut self) { TEST_SINK.with(|slot| { slot.borrow_mut().take(); }); } + } + let reset = Reset; + action(); + drop(reset); + let mut bytes = Vec::new(); + drain(&mut bytes, receiver, 1, 0, &health); + assert_eq!(health.dropped.load(Relaxed), 0); + bytes.split(|byte| *byte == b'\n').filter(|line| !line.is_empty()) + .map(|line| serde_json::from_slice(line).unwrap()).collect() + } + + fn encode(record: Record, run: u128, started_ms: u128, health: &HealthState) -> io::Result> { + let operation = record.operation; + let mut line = serde_json::to_vec(&serde_json::json!({ + "schema": "diagnosing-bugs.v1", "session_id": SESSION, + "run_id": format!("r{run:x}"), "build_id": build_id(), + "attempt_id": ATTEMPT, "change_id": CHANGE, + "probe_id": operation.probe, "hypothesis_ids": operation.probe.hypotheses(), + "operation_id": operation.id, "parent_operation_id": (operation.parent != 0).then_some(operation.parent), + "parent_capture": if operation.parent == 0 { "unknown" } else { "source_local" }, + "thread_label": record.thread_label, + "event_seq": record.seq, "event": record.event, "outcome": record.outcome, + "safe": record.facts, "monotonic_us": record.elapsed_us, + "capture_health": health.snapshot_value(), + "frontend_coverage": { + "scope": "backend_only", + "request_intent": "unknown", + "request_send": "unknown", + "response_application": "unknown", + "transport_loss": "unknown" + }, + "tail_capture": "unknown", "flush_durability": "os_not_fsync", + "ts": (started_ms + u128::from(record.elapsed_us / 1000)).to_string(), + }))?; + line.push(b'\n'); + Ok(line) + } + + fn write_record(writer: &mut impl Write, record: Record, run: u128, start: u128, + health: &HealthState) -> io::Result<()> { + writer.write_all(&encode(record, run, start, health)?)?; + health.written.fetch_add(1, Relaxed); + writer.flush()?; + health.flushed.fetch_add(1, Relaxed); + Ok(()) + } + + fn drain(mut writer: impl Write, rx: Receiver, run: u128, start: u128, + health: &HealthState) { + health.ready.store(true, Relaxed); + for record in rx { + if write_record(&mut writer, record, run, start, health).is_err() { + health.io_failed.store(true, Relaxed); + break; + } + } + health.ready.store(false, Relaxed); + } + + #[cfg(all(target_os = "windows", feature = "issue-1338-diagnostics", not(test)))] + pub(super) fn initialize() { + SINK.get_or_init(|| { + let (sender, receiver) = sync_channel(512); + let health = Arc::new(HealthState::default()); + health.initialized.store(true, Relaxed); + let writer_health = Arc::clone(&health); + let started = Instant::now(); + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default(); + if std::thread::Builder::new().name("issue-1338-writer".into()).spawn(move || { + // Only the writer touches the session-owned append-only file. + let file = (|| -> io::Result { + let base = std::env::var_os("LOCALAPPDATA") + .filter(|value| !value.is_empty()) + .ok_or_else(|| io::Error::other("unavailable"))?; + let directory = std::path::PathBuf::from(base).join("OpenLess").join("diagnostics"); + std::fs::create_dir_all(&directory)?; + std::fs::OpenOptions::new().create(true).append(true).open(directory.join(LOG_NAME)) + })(); + match file { + Ok(file) => drain(file, receiver, now.as_nanos(), now.as_millis(), &writer_health), + Err(_) => { writer_health.io_failed.store(true, Relaxed); }, + } + }).is_err() { health.spawn_failed.store(true, Relaxed); } + Sink { sender, health, started, run: now.as_nanos() } + }); + } + + #[cfg(test)] + mod tests { + use super::*; + #[test] + fn generic_guard_unlocks_before_emitting_release_and_keeps_temporary_lifetime() { + let mutex = Arc::new(parking_lot::Mutex::new(Some(3_u64))); + let check = Arc::clone(&mutex); + INSPECT.with(|hook| *hook.borrow_mut() = Some(Box::new(move |record| { + if matches!(record.event, Event::Released) { assert!(check.try_lock().is_some()); } + }))); + let records = capture(|| { + if let Some(value) = lock(&mutex, Kind::CacheLock).as_mut() { + *value = 4; + assert!(mutex.try_lock().is_none()); + } + assert_eq!(*mutex.lock(), Some(4)); + }); + INSPECT.with(|hook| { hook.borrow_mut().take(); }); + let events: Vec<_> = records.iter().map(|r| r["event"].as_str().unwrap()).collect(); + assert_eq!(events, ["requested", "acquired", "released", "scope_left"]); + } + #[test] + fn exact_production_capacity_and_loss_snapshot_are_explicit() { + let (sender, receiver) = sync_channel(512); + let sink = Sink { sender, health: Arc::default(), started: Instant::now(), run: 1 }; + for _ in 0..513 { sink.submit(sample()); } + assert_eq!(sink.health.dropped.load(Relaxed), 1); + let bytes = encode(sample(), 1, 0, &sink.health).unwrap(); + let record: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(record["capture_health"]["dropped"], 1); + assert_eq!(record["tail_capture"], "unknown"); + assert_eq!(record["flush_durability"], "os_not_fsync"); + drop(receiver); + } + #[test] + fn zero_backend_loss_does_not_claim_frontend_evidence_coverage() { + let bytes = encode(sample(), 1, 0, &HealthState::default()).unwrap(); + let record: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(record["capture_health"]["dropped"], 0); + assert_eq!(record["frontend_coverage"], serde_json::json!({ + "scope": "backend_only", + "request_intent": "unknown", + "request_send": "unknown", + "response_application": "unknown", + "transport_loss": "unknown" + })); + } + fn sample() -> Record { + Record { operation: Operation { id: 2, parent: 1, probe: Probe::P5 }, + event: Event::ScopeLeft, outcome: Outcome::Observed, + facts: Facts { kind: Kind::Microphone, value: 0, flag: false }, seq: 4, elapsed_us: 1234, thread_label: 7 } + } + #[test] + fn schema_is_typed_single_line_and_scope_left_is_not_success() { + let line = encode(sample(), 123, 456, &HealthState::default()).unwrap(); + assert_eq!(line.iter().filter(|byte| **byte == b'\n').count(), 1); + let value: serde_json::Value = serde_json::from_slice(&line).unwrap(); + for key in ["schema", "session_id", "run_id", "probe_id", "hypothesis_ids", "event", "outcome", "safe", "ts", "build_id", "capture_health", "tail_capture"] { + assert!(value.get(key).is_some(), "missing closed schema field"); + } + assert_eq!(value["session_id"], SESSION); + assert_eq!(value["attempt_id"], "A0-observation"); + assert_eq!(value["change_id"], "no-behavior-change"); + assert_eq!(value["schema"], "diagnosing-bugs.v1"); + assert_eq!(value["probe_id"], "P5"); + assert_eq!(value["hypothesis_ids"], serde_json::json!(["H2"])); + assert_eq!(value["event"], "scope_left"); + assert_eq!(value["outcome"], "observed"); + assert_eq!(value["parent_operation_id"], 1); + assert_eq!(value["event_seq"], 4); + assert_eq!(value["thread_label"], 7); + let safe = value["safe"].as_object().unwrap(); + assert_eq!(safe.keys().map(String::as_str).collect::>(), ["flag", "kind", "value"]); + assert!(safe.values().all(|v| v.is_boolean() || v.is_number() || v == "microphone")); + } + #[test] + fn full_and_disconnected_are_counted_without_waiting() { + let (sender, receiver) = sync_channel(1); + let sink = Sink { sender, health: Arc::default(), started: Instant::now(), run: 1 }; + sink.submit(sample()); + sink.submit(sample()); + assert_eq!(sink.health.dropped.load(Relaxed), 1); + assert_eq!(sink.health.queue_closed.load(Relaxed), 0); + receiver.try_recv().unwrap(); + drop(receiver); + sink.submit(sample()); + assert_eq!(sink.health.dropped.load(Relaxed), 2); + assert_eq!(sink.health.queue_closed.load(Relaxed), 1); + } + struct FailWrite; + impl Write for FailWrite { + fn write(&mut self, _: &[u8]) -> io::Result { Err(io::Error::other("injected")) } + fn flush(&mut self) -> io::Result<()> { panic!("must not flush after a failed write") } + } + #[test] + fn write_failure_does_not_claim_a_written_or_flushed_record() { + let (tx, rx) = sync_channel(1); + tx.send(sample()).unwrap(); drop(tx); + let health = HealthState::default(); + drain(FailWrite, rx, 1, 2, &health); + assert!(health.io_failed.load(Relaxed)); + assert!(!health.ready.load(Relaxed)); + assert_eq!(health.written.load(Relaxed), 0); + assert_eq!(health.flushed.load(Relaxed), 0); + } + struct FailFlush(Vec); + impl Write for FailFlush { + fn write(&mut self, bytes: &[u8]) -> io::Result { self.0.write(bytes) } + fn flush(&mut self) -> io::Result<()> { Err(io::Error::other("injected")) } + } + #[test] + fn io_failure_retains_independent_write_and_flush_watermarks() { + let (tx, rx) = sync_channel(1); + tx.send(sample()).unwrap(); drop(tx); + let health = HealthState::default(); + drain(FailFlush(Vec::new()), rx, 1, 2, &health); + assert!(health.io_failed.load(Relaxed)); + assert!(!health.ready.load(Relaxed)); + assert_eq!(health.written.load(Relaxed), 1); + assert_eq!(health.flushed.load(Relaxed), 0); + } + #[test] + fn successful_writer_records_every_complete_line() { + let (tx, rx) = sync_channel(2); + tx.send(sample()).unwrap(); tx.send(sample()).unwrap(); drop(tx); + let health = HealthState::default(); + let mut bytes = Vec::new(); + drain(&mut bytes, rx, 1, 2, &health); + assert_eq!(health.flushed.load(Relaxed), 2); + assert_eq!(bytes.split(|b| *b == b'\n').filter(|line| !line.is_empty()).count(), 2); + } + #[test] + fn probe_sources_have_independent_sequences() { + let first = SEQUENCES[0].fetch_add(1, Relaxed); + SEQUENCES[1].fetch_add(1, Relaxed); + assert_eq!(SEQUENCES[0].fetch_add(1, Relaxed), first + 1); + } + } +} + +#[cfg(test)] +mod boundary_tests { + use super::*; + use std::future::Future; + use std::task::{Context, Poll, Waker}; + #[test] + fn disabled_facade_and_test_initialize_never_open_real_files() { + initialize(); + assert!(sink::SINK.get().is_none()); + if !enabled() { + assert!(!health().initialized); + assert!(!health().ready); + assert!(health().run_id.is_none()); + } + assert!(build_id() == "unbound" || (build_id().len() == 40 && build_id().bytes().all(|b| b.is_ascii_hexdigit()))); + } + #[test] + fn command_allowlist_redacts_unknown_names_and_startup_is_covered() { + for name in ["get_settings", "list_channels", "read_credential", "get_startup_snapshot", "check_accessibility_permission", "check_microphone_permission"] { + assert!(Kind::command(name).is_some()); + } + for secret in ["", "personal-window", "secret-token", "get_settings extra-args", "plugin:untrusted|value"] { + assert!(Kind::command(secret).is_none()); + } + assert_eq!(Probe::P2.hypotheses(), ["H1", "H2", "H3", "H5"]); + } + #[test] + fn cancellation_is_scope_left_and_poll_context_never_leaks_across_await() { + let records = sink::capture(|| { + let outer = Operation::new(Probe::P6); + let _outer = SyncContext::enter(outer); + let mut future = Box::pin(observe_future(Kind::ListChannels, async { + assert_ne!(current().unwrap().id, outer.id); + std::future::pending::<()>().await; + })); + fn require_send(_: &T) {} + require_send(&future); + let mut cx = Context::from_waker(Waker::noop()); + assert!(matches!(future.as_mut().poll(&mut cx), Poll::Pending)); + assert_eq!(current().unwrap().id, outer.id); + drop(future); + assert_eq!(current().unwrap().id, outer.id); + }); + let events: Vec<_> = records.iter().map(|r| r["event"].as_str().unwrap()).collect(); + assert_eq!(events, ["entered", "scope_left"]); + assert!(records.iter().all(|r| r["outcome"] == "observed")); + } + #[test] + fn successful_future_returns_identical_result_and_real_sync_children() { + let records = sink::capture(|| { + let mut future = Box::pin(observe_future(Kind::ReadCredential, async { + let parent = current().unwrap(); + let worker = Operation::new(Probe::P2); + assert_eq!(worker.parent, parent.id); + Err::<(), u64>(7) + })); + let mut cx = Context::from_waker(Waker::noop()); + assert_eq!(future.as_mut().poll(&mut cx), Poll::Ready(Err(7))); + assert!(current().is_none()); + }); + assert_eq!(records[1]["event"], "returned"); + assert_eq!(records[1]["outcome"], "returned"); // Not business success. + } + #[test] + fn dispatch_acceptance_is_not_execution_and_nested_parent_is_source_local() { + let records = sink::capture(|| { + let dispatch = MainDispatch::new(Kind::HostDispatch); + let queued = dispatch.wrap(|| { + let child = MainDispatch::new(Kind::CapsuleDispatch); + child.wrap(|| {})(); + child.dispatched(true); + }); + dispatch.dispatched(true); + queued(); + }); + assert_eq!(records[0]["event"], "requested"); + assert_eq!(records[1]["event"], "dispatch_returned"); + assert_eq!(records[2]["event"], "entered"); + assert_eq!(records[3]["parent_operation_id"], records[0]["operation_id"]); + assert!(current().is_none()); + } + #[test] + fn unwinding_restores_context_and_never_reports_returned() { + let records = sink::capture(|| { + let result = std::panic::catch_unwind(|| { + MainDispatch::new(Kind::HostDispatch).wrap(|| panic!("injected"))(); + }); + assert!(result.is_err()); + assert!(current().is_none()); + }); + assert!(!records.iter().any(|r| r["event"] == "returned")); + assert_eq!(records.last().unwrap()["event"], "scope_left"); + } + + #[test] + fn inline_and_rejected_dispatch_do_not_invent_queued_execution() { + let records = sink::capture(|| { + let inline = MainDispatch::new(Kind::HostDispatch); + inline.wrap(|| {})(); + inline.dispatched(true); + let rejected = MainDispatch::new(Kind::CapsuleDispatch); + let task = rejected.wrap(|| panic!("not executed")); + drop(task); + rejected.dispatched(false); + }); + let events: Vec<_> = records.iter().map(|r| r["event"].as_str().unwrap()).collect(); + assert_eq!(events, ["requested", "entered", "returned", "scope_left", "dispatch_returned", "requested", "dispatch_returned"]); + assert_eq!(records.last().unwrap()["outcome"], "error"); + } + #[test] + fn movable_future_restores_tls_on_each_worker_poll() { + use std::sync::{Arc, atomic::{AtomicU64, Ordering::Relaxed}}; + let polls = Arc::new(AtomicU64::new(0)); + let counter = Arc::clone(&polls); + let mut future = Box::pin(observe_future(Kind::StartupSnapshot, std::future::poll_fn(move |_| { + assert!(current().is_some()); + if counter.fetch_add(1, Relaxed) == 0 { Poll::Pending } else { Poll::Ready(9_u64) } + }))); + let mut cx = Context::from_waker(Waker::noop()); + assert_eq!(future.as_mut().poll(&mut cx), Poll::Pending); + assert!(current().is_none()); + std::thread::spawn(move || { + assert!(current().is_none()); + let mut cx = Context::from_waker(Waker::noop()); + assert_eq!(future.as_mut().poll(&mut cx), Poll::Ready(9)); + assert!(current().is_none()); + }).join().unwrap(); + assert_eq!(polls.load(Relaxed), 2); + } +} diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 1116e7eed..af88f283e 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -72,6 +72,7 @@ mod hotkey; mod hotkey; mod insertion; mod issue1266_debug; +mod issue1338_debug; #[cfg(target_os = "android")] mod style_zip_transfer; mod llm_gemini; @@ -217,6 +218,7 @@ pub(crate) fn set_backend_preferences_for_test( #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + issue1338_debug::initialize(); issue1266_debug::initialize(); tls_crypto::install_tls_crypto_provider(); // Explicit acceptance in a debug bundle bypasses UI, microphone and the @@ -562,6 +564,8 @@ macro_rules! app_invoke_handler_desktop { log_client_error, #[cfg(all(target_os = "windows", feature = "issue-1266-diagnostics"))] issue1266_health, + #[cfg(all(target_os = "windows", feature = "issue-1338-diagnostics"))] + issue1338_health, set_windows_caption_theme, ] }; @@ -1051,7 +1055,12 @@ fn run_desktop() { .show_menu_on_left_click(false) .on_menu_event(move |app, event| match event.id.as_ref() { "toggle" => show_main_window(app), - "quit" => request_quit(app), + "quit" => { + let boundary = issue1338_debug::Boundary::enter(issue1338_debug::Probe::P6, issue1338_debug::Kind::TrayQuit); + let _context = issue1338_debug::SyncContext::enter(boundary.operation); + request_quit(app); + boundary.returned(); + }, id => { if handle_style_tray_menu_event(app, id) { return; @@ -1104,10 +1113,11 @@ fn run_desktop() { Ok(()) }) - .invoke_handler(app_invoke_handler_desktop!()) + .invoke_handler(observe_issue1338_invoke(app_invoke_handler_desktop!())) .build(context) .expect("error while building tauri application") .run(|app, event| { + observe_issue1338_lifecycle(&event); #[cfg(all(target_os = "windows", feature = "issue-1266-diagnostics"))] observe_issue1266_lifecycle(&event); match event { @@ -1167,6 +1177,44 @@ fn run_desktop() { }); } +// [DEBUG-ISSUE-1338] Tauri 2.12.4 ipc::InvokeHandler is Fn(Invoke) -> bool. +// The bool is handled/dispatch return, NOT the eventual async command result. +fn observe_issue1338_invoke( + handler: impl Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static, +) -> impl Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static { + move |invoke| { + use issue1338_debug::{Boundary, Event, Kind, Outcome, Probe, SyncContext}; + if !issue1338_debug::enabled() { return handler(invoke); } + let Some(kind) = Kind::command(invoke.message.command()) else { return handler(invoke); }; + let boundary = Boundary::enter(Probe::P1, kind); + let _context = SyncContext::enter(boundary.operation); + let handled = handler(invoke); + boundary.record(Event::DispatchReturned, if handled { Outcome::Ok } else { Outcome::Error }); + handled + } +} +// Optional read-only counters, not an emergency transport: a stuck IPC may +// also prevent this command. Copy the append-only session file manually. +#[cfg(all(target_os = "windows", feature = "issue-1338-diagnostics"))] +#[tauri::command] +fn issue1338_health() -> issue1338_debug::Health { issue1338_debug::health() } + +fn observe_issue1338_lifecycle(event: &RunEvent) { + use issue1338_debug::{Boundary, Event, Kind, Outcome, Probe}; + let event = match event { + RunEvent::Ready => Event::Ready, + RunEvent::ExitRequested { .. } => Event::ExitRequested, + RunEvent::Exit => Event::Exit, + RunEvent::WindowEvent { event: tauri::WindowEvent::CloseRequested { .. }, .. } => Event::CloseRequested, + RunEvent::WindowEvent { event: tauri::WindowEvent::Destroyed, .. } => Event::Destroyed, + _ => return, + }; + // No arbitrary window labels; no assertion that Exit proves process termination. + let boundary = Boundary::enter(Probe::P6, Kind::None); + boundary.record(event, Outcome::Observed); + boundary.returned(); +} + #[cfg(all(target_os = "windows", feature = "issue-1266-diagnostics"))] fn observe_issue1266_lifecycle(event: &RunEvent) { use issue1266_debug::{Event, Facts, Kind, Operation, Outcome, Probe}; @@ -1872,6 +1920,7 @@ const QUIT_FORCE_EXIT_GRACE: Duration = Duration::from_secs(3); /// Arm the fallback before requesting graceful exit on explicit user intent. fn request_quit(app: &tauri::AppHandle) { + let boundary = issue1338_debug::Boundary::enter(issue1338_debug::Probe::P6, issue1338_debug::Kind::QuitRequest); std::thread::spawn(|| { std::thread::sleep(QUIT_FORCE_EXIT_GRACE); // Keep the deadline path independent of application locks: synchronous @@ -1881,6 +1930,7 @@ fn request_quit(app: &tauri::AppHandle) { std::process::exit(0); }); app.exit(0); + boundary.returned(); } #[tauri::command] diff --git a/openless-all/app/src-tauri/src/permissions.rs b/openless-all/app/src-tauri/src/permissions.rs index 55a899592..cdf9d219b 100644 --- a/openless-all/app/src-tauri/src/permissions.rs +++ b/openless-all/app/src-tauri/src/permissions.rs @@ -601,9 +601,35 @@ mod platform { } } -pub use platform::{ - check_accessibility, check_microphone, request_accessibility, request_microphone, -}; +pub use platform::{request_accessibility, request_microphone}; +#[cfg(not(all(target_os = "windows", feature = "issue-1338-diagnostics")))] +pub use platform::{check_accessibility, check_microphone}; + +#[cfg(all(target_os = "windows", feature = "issue-1338-diagnostics"))] +fn observe_native_permission(kind: crate::issue1338_debug::Kind, check: impl FnOnce() -> PermissionStatus) -> PermissionStatus { + use crate::issue1338_debug::{Boundary, Event, Outcome, Probe, SyncContext}; + let boundary = Boundary::enter(Probe::P5, kind); + let _context = SyncContext::enter(boundary.operation); + let status = check(); + let outcome = match status { + PermissionStatus::Granted => Outcome::Granted, + PermissionStatus::Denied => Outcome::Denied, + PermissionStatus::NotDetermined => Outcome::NotDetermined, + PermissionStatus::Restricted => Outcome::Restricted, + PermissionStatus::NotApplicable => Outcome::NotApplicable, + PermissionStatus::NoDevice => Outcome::NoDevice, + }; + boundary.record(Event::Returned, outcome); + status +} +#[cfg(all(target_os = "windows", feature = "issue-1338-diagnostics"))] +pub fn check_accessibility() -> PermissionStatus { + observe_native_permission(crate::issue1338_debug::Kind::Accessibility, platform::check_accessibility) +} +#[cfg(all(target_os = "windows", feature = "issue-1338-diagnostics"))] +pub fn check_microphone() -> PermissionStatus { + observe_native_permission(crate::issue1338_debug::Kind::Microphone, platform::check_microphone) +} #[cfg(all(not(target_os = "macos"), not(target_os = "android")))] pub use platform::has_microphone_input_device; diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index e123505ca..eeace4fbb 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -176,7 +176,7 @@ fn mutate_credentials_with( .map(|gate| gate.begin_mutation()) .transpose() .map_err(sync_access_error)?; - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); // First binding may win the lock after this worker observed no gate. // Such a worker must retry; it cannot write through a newly installed barrier. if was_unbound && sync_write_gate().is_some() { @@ -565,7 +565,7 @@ fn last_vault_read_error_logged_slot() -> &'static Mutex> { } fn store_credentials_cache(root: &CredsRoot) { - *credentials_cache().lock() = Some(root.clone()); + *crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock) = Some(root.clone()); clear_vault_read_error(); } @@ -612,7 +612,7 @@ fn clear_vault_read_error() { #[cfg(test)] fn reset_credentials_cache_for_tests() { - *credentials_cache().lock() = None; + *crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock) = None; clear_vault_read_error(); } @@ -1738,7 +1738,7 @@ fn invalidate_marketplace_token_process_local() { // the command layer's point of view; the atomic also prevents accidental // direct readers from observing the rejected token. MARKETPLACE_TOKEN_REJECTED.store(true, Ordering::SeqCst); - if let Some(root) = credentials_cache().lock().as_mut() { + if let Some(root) = crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock).as_mut() { write_marketplace_github_token(root, None); } #[cfg(target_os = "android")] @@ -1807,7 +1807,7 @@ fn remove_marketplace_token_with( if result.is_err() { // Read/gate failures happen before the update closure. Reassert the local // revocation after any earlier in-flight login has left its vault section. - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); invalidate_marketplace_token_process_local(); } result @@ -2427,7 +2427,7 @@ fn load_credentials_for_sync_binding_with( fn load_credentials_readonly_into_cache_with( loader: impl FnOnce() -> Result, ) -> Result { - if let Some(cached) = credentials_cache().lock().as_ref().cloned() { + if let Some(cached) = crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock).as_ref().cloned() { return Ok(cached); } match loader() { @@ -2444,7 +2444,7 @@ fn load_credentials_readonly_into_cache_with( } fn load_credentials_raw() -> CredsRoot { - if let Some(cached) = credentials_cache().lock().as_ref().cloned() { + if let Some(cached) = crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock).as_ref().cloned() { return cached; } @@ -2473,7 +2473,7 @@ fn load_credentials_raw() -> CredsRoot { } fn load_credentials_for_update_raw() -> Result { - if let Some(cached) = credentials_cache().lock().as_ref().cloned() { + if let Some(cached) = crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock).as_ref().cloned() { return Ok(cached); } @@ -2530,7 +2530,7 @@ fn finish_credential_write(root: &CredsRoot, outcome: Result<()>) -> Result<()> error.downcast_ref::(), Some(VaultCommitFailure::Unchanged) ) { - *credentials_cache().lock() = None; + *crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock) = None; } Err(error) } @@ -3502,7 +3502,7 @@ impl CredentialsVault { pub(crate) fn bind_sync_gate( gate: std::sync::Arc, ) -> Result<()> { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); let mut installed = SYNC_WRITE_GATE.get_or_init(|| Mutex::new(None)).lock(); // Binding is deliberately independent of OS authorization and migration. // Status/read/export lazily inspect the native store under this barrier; @@ -3512,7 +3512,7 @@ impl CredentialsVault { pub(crate) fn export_sync_credentials_readonly( ) -> Result { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); // Cold capture uses the same strict read-only loader as bound getters. // Legacy sources remain readable without migration, even before startup // recovery. Failed OS access leaves the next capture free to retry. @@ -3532,7 +3532,7 @@ impl CredentialsVault { permit: &openless_core::credentials::ExclusivePermit, ) -> Result { require_sync_exclusive(permit)?; - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); capture_sync_credentials_with(load_credentials_for_update) } @@ -3541,7 +3541,7 @@ impl CredentialsVault { permit: &openless_core::credentials::ExclusivePermit, ) -> Result<()> { require_sync_exclusive(permit)?; - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); let current = load_credentials_for_update()?; let replacement = apply_sync_credentials_root(¤t, snapshot)?; // The outer restore transaction owns the exclusive generation/receipt. @@ -3551,7 +3551,7 @@ impl CredentialsVault { pub(crate) fn read_sync_secret( account: &openless_core::credentials::SyncSecretAccount, ) -> Result> { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); read_sync_secret_raw(account) } @@ -3560,7 +3560,7 @@ impl CredentialsVault { value: &openless_core::SecretValue, ) -> Result<()> { validate_sync_key(value)?; - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); if let Some(existing) = read_sync_secret_raw(account)? { if existing == *value { return Ok(()); @@ -3580,7 +3580,7 @@ impl CredentialsVault { pub(crate) fn remove_sync_secret( account: &openless_core::credentials::SyncSecretAccount, ) -> Result<()> { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); remove_sync_secret_raw(account) } @@ -3602,7 +3602,7 @@ impl CredentialsVault { } pub fn load_metadata() -> Result { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); let metadata = credential_metadata(&load_credentials_for_update()?); #[cfg(target_os = "macos")] let metadata = metadata.retired_macos_view(); @@ -3622,7 +3622,7 @@ impl CredentialsVault { } pub fn channel_has_secrets(kind: ChannelKind, id: &str) -> Result { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); Ok(channel_has_secrets( &load_credentials_for_update()?, kind, @@ -3631,7 +3631,7 @@ impl CredentialsVault { } pub fn get(account: CredentialAccount) -> Result> { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); Ok(lookup_account(&load_credentials_for_update()?, account)) } @@ -3647,7 +3647,7 @@ impl CredentialsVault { } pub fn get_for_asr_provider(id: &str, account: CredentialAccount) -> Result> { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); let mut root = load_credentials_for_update()?; root.active.asr = id.to_string(); Ok(lookup_account(&root, account)) @@ -3679,7 +3679,7 @@ impl CredentialsVault { /// This credential deliberately has no generic `CredentialAccount` and is /// excluded from `CredentialsSnapshot`, so frontend IPC can never read it. pub fn get_marketplace_github_token() -> Result> { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); if marketplace_token_is_rejected() { return Ok(None); } @@ -3701,7 +3701,7 @@ impl CredentialsVault { pub fn set_marketplace_github_token(value: &str) -> Result<()> { #[cfg(target_os = "android")] { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); ensure_android_marketplace_legacy_scrubbed()?; *android_marketplace_token().lock() = (!value.trim().is_empty()).then(|| MarketplaceGithubToken(value.to_string())); @@ -3725,7 +3725,7 @@ impl CredentialsVault { #[cfg(target_os = "android")] { invalidate_marketplace_token_process_local(); - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); invalidate_marketplace_token_with(ensure_android_marketplace_legacy_scrubbed) } #[cfg(not(target_os = "android"))] @@ -3741,7 +3741,7 @@ impl CredentialsVault { /// (no "rejected" tombstone) — it is a plain opaque secret, same tier as /// an ASR/LLM API key. pub fn get_cloud_sync_custom_token() -> Result> { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); Ok(lookup_cloud_sync_custom_token( &load_credentials_for_update()?, )) @@ -3766,7 +3766,7 @@ impl CredentialsVault { #[cfg(test)] pub(crate) fn seed_marketplace_github_token_for_tests(value: &str) { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); let mut root = CredsRoot::default(); write_marketplace_github_token(&mut root, Some(value.to_string())); store_credentials_cache(&root); @@ -3777,13 +3777,13 @@ impl CredentialsVault { pub(crate) fn reject_marketplace_github_token_for_tests( durable_delete: impl FnOnce() -> Result<()>, ) -> Result<()> { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); invalidate_marketplace_token_with(durable_delete) } #[cfg(test)] pub(crate) fn reset_marketplace_github_token_for_tests() { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); store_credentials_cache(&CredsRoot::default()); MARKETPLACE_TOKEN_REJECTED.store(false, Ordering::SeqCst); } @@ -3796,7 +3796,7 @@ impl CredentialsVault { /// need the vendor id. Convert here so those call sites stay unchanged. /// Use `get_active_asr_channel_id` when the channel id itself is needed. pub fn get_active_asr() -> String { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); let root = load_credentials(); let id = root.active.asr.clone(); root.providers @@ -3816,7 +3816,7 @@ impl CredentialsVault { /// The current LLM channel's **vendor id (providerType)**. Same rationale as `get_active_asr`. pub fn get_active_llm() -> String { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); let root = load_credentials(); let id = root.active.llm.clone(); root.providers @@ -3828,7 +3828,7 @@ impl CredentialsVault { /// Custom request headers for a specific LLM channel (used by connection tests; use `get_active_llm_extra_headers` when no channel is passed). pub fn get_llm_extra_headers_for_channel(id: &str) -> HashMap { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); let mut root = load_credentials(); root.active.llm = id.to_string(); active_llm_extra_headers(&root) @@ -3836,7 +3836,7 @@ impl CredentialsVault { /// Sampling temperature for a specific LLM channel. pub fn get_llm_temperature_for_channel(id: &str) -> Option { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); let mut root = load_credentials(); root.active.llm = id.to_string(); active_llm_temperature(&root) @@ -3847,7 +3847,7 @@ impl CredentialsVault { /// ASR has long had `get_for_asr_provider`; the LLM side could originally only read the /// "current active" entry, and after channelization must read any card. pub fn get_for_llm_provider(id: &str, account: CredentialAccount) -> Result> { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); let mut root = load_credentials_for_update()?; root.active.llm = id.to_string(); Ok(lookup_account(&root, account)) @@ -3868,7 +3868,7 @@ impl CredentialsVault { } pub fn get_active_omni() -> String { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); load_credentials().omni.active } @@ -3892,7 +3892,7 @@ impl CredentialsVault { } pub fn get_for_omni_provider(id: &str, account: CredentialAccount) -> Result> { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); lookup_omni_account(&load_credentials(), id, account) } @@ -3909,32 +3909,32 @@ impl CredentialsVault { } pub fn get_active_omni_extra_headers() -> HashMap { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); active_omni_extra_headers(&load_credentials()) } pub fn get_active_omni_extra_headers_json() -> Result> { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); active_omni_extra_headers_json(&load_credentials()) } pub fn get_omni_extra_headers_json_for_provider(id: &str) -> Result> { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); omni_extra_headers_json(&load_credentials(), id) } pub fn get_active_omni_temperature() -> Option { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); active_omni_temperature(&load_credentials()) } pub fn get_active_omni_temperature_string() -> Option { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); active_omni_temperature_string(&load_credentials()) } pub fn get_omni_temperature_string_for_provider(id: &str) -> Option { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); omni_temperature_string(&load_credentials(), id) } @@ -3987,22 +3987,22 @@ impl CredentialsVault { } pub fn get_active_llm_extra_headers() -> HashMap { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); active_llm_extra_headers(&load_credentials()) } pub fn get_active_llm_extra_headers_json() -> Result> { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); active_llm_extra_headers_json(&load_credentials()) } pub fn get_active_llm_temperature() -> Option { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); active_llm_temperature(&load_credentials()) } pub fn get_active_llm_temperature_string() -> Option { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); active_llm_temperature_string(&load_credentials()) } @@ -4019,7 +4019,7 @@ impl CredentialsVault { } pub fn get_llm_protocol_option(id: Option<&str>, account: &str) -> Result> { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); let mut root = load_credentials_for_update()?; let id = id.unwrap_or(&root.active.llm).to_string(); match root.providers.llm.get_mut(&id) { @@ -4076,7 +4076,7 @@ impl CredentialsVault { } pub fn snapshot_for_pipeline(include_omni: bool) -> CredentialsSnapshot { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); let root = load_credentials(); credentials_snapshot(&root, include_omni) } @@ -4084,7 +4084,7 @@ impl CredentialsVault { pub(crate) fn configuration_snapshot( include_omni: bool, ) -> Result { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); configuration_snapshot_with(include_omni, load_credentials_for_update) } } @@ -4811,7 +4811,7 @@ mod tests { .context("read Android credential envelope")) }); assert!(lookup_marketplace_github_token(&first).is_none()); - assert!(credentials_cache().lock().is_none()); + assert!(crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock).is_none()); let first_error = CredentialsVault::last_read_error().expect("vault error should be recorded"); assert!( @@ -4830,13 +4830,13 @@ mod tests { let second = load_credentials_into_cache_with(|| load_android_credentials_from_path(&path)); assert!(lookup_marketplace_github_token(&second).is_none()); - assert!(credentials_cache().lock().is_some()); + assert!(crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock).is_some()); assert!( CredentialsVault::last_read_error().is_none(), "successful read must clear the last vault error" ); assert_android_secret_unrecoverable(&path, "gho_legacy_startup_secret"); - *credentials_cache().lock() = Some(CredsRoot::default()); + *crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock) = Some(CredsRoot::default()); let _ = std::fs::remove_dir_all(dir); } @@ -4861,7 +4861,7 @@ mod tests { .context("read Android credential envelope")) }) .expect_err("mutations must not receive a default root to persist"); - assert!(credentials_cache().lock().is_none()); + assert!(crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock).is_none()); let error = CredentialsVault::last_read_error().expect("vault error should be recorded"); assert!( error.contains("temporarily unavailable"), @@ -5565,12 +5565,12 @@ mod encrypted_sync_tests { }) .unwrap_err(); assert!(failure.to_string().contains("fixture native access denied")); - assert!(credentials_cache().lock().is_none()); + assert!(crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock).is_none()); let recorded = CredentialsVault::last_read_error().unwrap(); // A cache hit itself is not a successful OS read. Seed only the cache // slot, without the successful-load/save helper's error-latch update. - *credentials_cache().lock() = Some(root_with_secret("fixture-cached")); + *crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock) = Some(root_with_secret("fixture-cached")); let cached = load_credentials_readonly_into_cache_with(|| panic!("must use cache")).unwrap(); assert_eq!( @@ -5582,7 +5582,7 @@ mod encrypted_sync_tests { Some(recorded.as_str()) ); - *credentials_cache().lock() = None; + *crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock) = None; let loaded = load_credentials_readonly_into_cache_with(|| { Ok(root_with_secret("fixture-retry-succeeded")) }) @@ -5591,7 +5591,7 @@ mod encrypted_sync_tests { loaded.providers.asr["stable-channel"].apiKey.as_deref(), Some("fixture-retry-succeeded") ); - assert!(credentials_cache().lock().is_some()); + assert!(crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock).is_some()); assert!(CredentialsVault::last_read_error().is_none()); reset_credentials_cache_for_tests(); } @@ -6082,13 +6082,13 @@ mod encrypted_sync_tests { store_credentials_cache(&old); assert!(finish_credential_write(&new, Err(VaultCommitFailure::Unchanged.into())).is_err()); assert_eq!( - credentials_cache().lock().as_ref().unwrap().providers.asr["stable-channel"] + crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock).as_ref().unwrap().providers.asr["stable-channel"] .apiKey .as_deref(), Some("old") ); assert!(finish_credential_write(&new, Err(VaultCommitFailure::Unknown.into())).is_err()); - assert!(credentials_cache().lock().is_none()); + assert!(crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock).is_none()); let count = Cell::new(0); let loaded = load_credentials_into_cache_with(|| { count.set(count.get() + 1); diff --git a/openless-all/app/src-tauri/src/persistence/credentials/android_transfer.rs b/openless-all/app/src-tauri/src/persistence/credentials/android_transfer.rs index c9239e372..52eec8088 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials/android_transfer.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials/android_transfer.rs @@ -169,7 +169,7 @@ fn merge_channels( impl CredentialsVault { pub(crate) fn export_android_channels(secrets: bool) -> Result { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); let root = load_credentials_for_update()?; let channels = Channels { asr: root @@ -206,7 +206,7 @@ impl CredentialsVault { selection: &serde_json::Value, credentials: Option<&serde_json::Value>, ) -> Result { - let _guard = credentials_lock().lock(); + let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); let root = load_credentials_for_update()?; let mut channels = Channels::default(); for (slot, field) in [("asr", "activeAsrProvider"), ("llm", "activeLlmProvider")] { diff --git a/openless-all/app/src-tauri/src/tauri_coordinator_host.rs b/openless-all/app/src-tauri/src/tauri_coordinator_host.rs index 12fe1f216..2154d29c4 100644 --- a/openless-all/app/src-tauri/src/tauri_coordinator_host.rs +++ b/openless-all/app/src-tauri/src/tauri_coordinator_host.rs @@ -1095,22 +1095,28 @@ impl TauriCapsuleWindow { where F: FnOnce(Self) + Send + 'static, { - #[cfg(all(target_os = "windows", feature = "issue-1266-diagnostics"))] - { - let dispatch = crate::issue1266_debug::MainDispatch::new(kind); - let capsule = dispatch.clone_handle(|| self.clone()); - let result = self.app.run_on_main_thread(dispatch.wrap(move || task(capsule))) - .map_err(|error| error.to_string()); - dispatch.dispatched(result.is_ok()); - result - } - #[cfg(not(all(target_os = "windows", feature = "issue-1266-diagnostics")))] - { - let _ = kind; - let capsule = self.clone(); - self.app.run_on_main_thread(move || task(capsule)) - .map_err(|error| error.to_string()) - } + let dispatch1338 = crate::issue1338_debug::MainDispatch::new(crate::issue1338_debug::Kind::CapsuleDispatch); + let task = move |capsule| dispatch1338.wrap(move || task(capsule))(); + let result1338 = (|| { + #[cfg(all(target_os = "windows", feature = "issue-1266-diagnostics"))] + { + let dispatch = crate::issue1266_debug::MainDispatch::new(kind); + let capsule = dispatch.clone_handle(|| self.clone()); + let result = self.app.run_on_main_thread(dispatch.wrap(move || task(capsule))) + .map_err(|error| error.to_string()); + dispatch.dispatched(result.is_ok()); + result + } + #[cfg(not(all(target_os = "windows", feature = "issue-1266-diagnostics")))] + { + let _ = kind; + let capsule = self.clone(); + self.app.run_on_main_thread(move || task(capsule)) + .map_err(|error| error.to_string()) + } + })(); + dispatch1338.dispatched(result1338.is_ok()); + result1338 } fn restore_translation_menu(&self, owner: Option<(&str, openless_core::SessionId)>) -> Result { @@ -2088,6 +2094,8 @@ impl TauriCoordinatorHost { where F: FnOnce() + Send + 'static, { + let dispatch1338 = crate::issue1338_debug::MainDispatch::new(crate::issue1338_debug::Kind::HostDispatch); + let task = dispatch1338.wrap(task); #[cfg(feature = "issue-1266-diagnostics")] let dispatch = crate::issue1266_debug::MainDispatch::new(kind); #[cfg(feature = "issue-1266-diagnostics")] @@ -2097,6 +2105,7 @@ impl TauriCoordinatorHost { if MAIN_THREAD.get() == Some(&std::thread::current().id()) { task(); + dispatch1338.dispatched(true); #[cfg(feature = "issue-1266-diagnostics")] dispatch.dispatched(true); return; @@ -2108,6 +2117,7 @@ impl TauriCoordinatorHost { } else { false }; + dispatch1338.dispatched(queued); #[cfg(feature = "issue-1266-diagnostics")] dispatch.dispatched(queued); #[cfg(not(feature = "issue-1266-diagnostics"))] @@ -2309,23 +2319,29 @@ impl TauriCoordinatorHost { where F: FnOnce() + Send + 'static, { - #[cfg(all(target_os = "windows", feature = "issue-1266-diagnostics"))] - { - use crate::issue1266_debug::{Kind, MainDispatch}; - let dispatch = MainDispatch::new(Kind::HostDispatch); - let app = dispatch.clone_handle(|| self.app()); - let result = app.ok_or_else(|| "Tauri AppHandle is not bound".to_string()) - .and_then(|app| app.run_on_main_thread(dispatch.wrap(task)) - .map_err(|error| error.to_string())); - dispatch.dispatched(result.is_ok()); - result - } - #[cfg(not(all(target_os = "windows", feature = "issue-1266-diagnostics")))] - { - let app = self.app() - .ok_or_else(|| "Tauri AppHandle is not bound".to_string())?; - app.run_on_main_thread(task).map_err(|error| error.to_string()) - } + let dispatch1338 = crate::issue1338_debug::MainDispatch::new(crate::issue1338_debug::Kind::HostDispatch); + let task = dispatch1338.wrap(task); + let result1338 = (|| { + #[cfg(all(target_os = "windows", feature = "issue-1266-diagnostics"))] + { + use crate::issue1266_debug::{Kind, MainDispatch}; + let dispatch = MainDispatch::new(Kind::HostDispatch); + let app = dispatch.clone_handle(|| self.app()); + let result = app.ok_or_else(|| "Tauri AppHandle is not bound".to_string()) + .and_then(|app| app.run_on_main_thread(dispatch.wrap(task)) + .map_err(|error| error.to_string())); + dispatch.dispatched(result.is_ok()); + result + } + #[cfg(not(all(target_os = "windows", feature = "issue-1266-diagnostics")))] + { + let app = self.app() + .ok_or_else(|| "Tauri AppHandle is not bound".to_string())?; + app.run_on_main_thread(task).map_err(|error| error.to_string()) + } + })(); + dispatch1338.dispatched(result1338.is_ok()); + result1338 } pub(crate) fn spawn(&self, future: F) -> tauri::async_runtime::JoinHandle From b743c50abc2d9e04f59eaeee2fa3e124ba470331 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sat, 7 Nov 2026 21:02:21 +0800 Subject: [PATCH 2/4] fix(diagnostics): include current issue 1338 evidence in log export --- docs/research/issue-1338-feedback-20261107.md | 69 ++++ .../scripts/test-backend-hang-diagnostics.py | 16 + .../app/scripts/test-recorder-module.py | 1 + .../app/src-tauri/src/commands/hotkeys.rs | 7 + .../app/src-tauri/src/commands/misc.rs | 29 +- openless-all/app/src-tauri/src/commands/qa.rs | 7 + .../app/src-tauri/src/commands/settings.rs | 24 +- .../app/src-tauri/src/issue1266_debug.rs | 19 +- .../app/src-tauri/src/issue1338_debug.rs | 319 +++++++++++++++--- .../app/src-tauri/src/issue1338_export.rs | 213 ++++++++++++ openless-all/app/src-tauri/src/lib.rs | 9 +- 11 files changed, 665 insertions(+), 48 deletions(-) create mode 100644 docs/research/issue-1338-feedback-20261107.md create mode 100644 openless-all/app/src-tauri/src/issue1338_export.rs diff --git a/docs/research/issue-1338-feedback-20261107.md b/docs/research/issue-1338-feedback-20261107.md new file mode 100644 index 000000000..62878886d --- /dev/null +++ b/docs/research/issue-1338-feedback-20261107.md @@ -0,0 +1,69 @@ +# #1338 第二份现场:2026-11-07 日志与第一轮修复资格 + +## 输入与不可变边界 + +- 分支 HEAD:`e4f6102f11be2755d5c6917b09f9fafee9a568dc`,工作区 `D:/Dev/worktrees/openless/issue-1338-backend-hang`。不重建分支、不改原克隆;不混入 #1401 手改学词入口问题。 +- 只读输入:`openless-2026-11-07T12-24-42.log`,12236 行、1623785 字节。经逐行比较,前 11995 行与首份日志完全一致,新增 241 行。普通应用日志的格式有效;不因不是 NDJSON 而拒绝分析。 +- 用户反馈两次异常均发生在操作设置中的选区助手和「问任何问题」期间。这是用户报告的相关场景,不是已证实触发器、锁环或原因;准确故障时刻、现场安装 SHA、退出方式仍未知。 +- 本附件没有 `issue-1338-20261106` 记录;附件目录也没有收到对应 NDJSON。开发机器的约定路径不存在该文件,不将开发机器状态等同于报告者设备状态。 +- 唯一运行时会话日志保持 `%LOCALAPPDATA%/OpenLess/diagnostics/openless-issue-1338-20261106-session.ndjson`,不改名、不截断、不删旧记录;本地不初始化或运行应用。 +- 保留既有独立审查→提交→fork push→准确 head Actions 的门禁。用户已要求暂停因 ARM 容量连续失败的 CI 重试,不能把新日志当作授权改 runner 策略;新候选的执行范围需明确区分旧失败重跑与验证新代码。所有测试、依赖安装、编译、打包仍只用 Actions。 + +## 正常行为与语义检查 + +沿用原报告 N1(快捷键功能)、N2(设置/配置请求完成)、N3(权限状态)、N4(退出)、N5(局部进展不等于全局健康)。补充: + +- N6:进入/结束快捷键录制及保存选区助手/问任何问题设置后,应保留安全的快捷键注册与取消语义,设置保存和其他后端请求可以继续完成。来源:用户期望及现有设置/快捷键接口;具体锁顺序需源码核对,不能先设定未经验证的期望。 +- E1(诊断交付契约,与原卡死根因分开):明确安装诊断构建时,软件内日志导出应能携带本次专用诊断证据或明确告诉用户证据未被捕获,而不是普通日志导出成功被误认为已交付探针。现有 A0 只承诺手动复制专用文件,用户本次确实使用软件内导出;这暴露了需修补的交付可达性缺口,不证明原根因。 + +### 正向记录及边界 + +| 原始行号 | UTC 时间/观察 | 状态与限制 | +|---|---|---| +| 12118–12127 | 11:59:58–12:00:02,一次普通听写进入并出现 PasteSent 局部终态 | 对该后端局部操作 NO_VIOLATION_OBSERVED;不证明用户可见结果或之后全局健康 | +| 12128–12185 | 12:00:25–12:19:57,多次快捷键录制 active=true/false,synced_to_hotkey=true | 对“录制状态切换入口确实到达”有正向证据;没有设置保存、注册 ACK、共享 gate、主线程等待的完整链,N6 仍 INSUFFICIENT_EVIDENCE | +| 12186–12191 | hook/转发线程退出,其后 12:21:06 启动 | 生命周期片段 SUSPICIOUS;不能证明卡死、崩溃或强杀,不能确定与哪次操作对应 | +| 12199–12204 | 12:22:27,选区复制、预取、prefetch_hit 与目标捕获有正向局部成功 | 排除“本次选区从未捕获”作为这次请求的解释;不能证明 QA/助手业务或设置保存完成 | +| 12217–12226 | 12:23:15–17,另一轮普通听写到 PasteSent | 同上,局部完成不等于原报告结果恢复 | +| 12227–12236 | 12:24:20 又启动,随后单实例 relaunch | 不能由启动记录推断前次终止原因;故障发生准确时刻未知 | + +去掉所有 WARN/ERROR 后,用户报告的 N1/N2/N4/N6 功能停滞仍应作为异常观察,但现有 INFO 无法独立确认阻塞位置。语音唤醒模型未安装与旧登录错误没有足够关联,不把它们当作本轮原因。 + +## 原卡死假设与修复资格(本次证据后) + +| ID | Hypothesis | Falsifiable prediction | If true, what change would make the bug disappear? | Probe IDs | +|---|---|---|---|---| +| H1 | 设置/录音生命周期同步路径阻塞原生派发(N2/N6) | 对应设置命令已进入,preferences/warm configure 某阶段不能返回,并有现场线程栈对应 | 只迁移或修正已证实阻塞的同步阶段,保留取消、录音与锁生命周期 | P1/P2/P4 | +| H2 | 原生权限探测或其他同步调用占住 async 执行资源(N2/N3) | 具体原生阶段停滞,线程栈显示执行资源等待;不能仅由权限 Promise.all 未完成推断整体饥饿 | 只调整证实阻塞调用的执行边界,保留权限语义 | P1/P2/P5 | +| H3 | 凭据锁/worker 等待导致配置请求停滞(N2) | worker、锁与请求有真实关联且停在其一;有独立请求对照 | 只修证实的锁持有/worker卡点,保留凭据安全与原子性 | P2/P3 | +| H4 | 选区助手/QA 快捷键设置或注册在共享 host gate 与主线程派发之间形成阻塞(N1/N2/N4/N6) | 相关设置/录制/注册的请求、gate取得/释放、派发与ACK可关联,栈显示具体等待关系 | 只消除已证实的同步等待或锁环,不改变按键顺序、冲突检查、取消或安全边界 | P1/P2/P4/P6(需最小扩展既有类别) | +| H5 | 前端 readiness 阶段未完成,普通后端命令尚未发出(N2/N3/N5) | 同一页面的 startup pending;后端接收缺失不能单独证明未发 | 只修证据定位的 startup 阶段,保留恢复和生命周期屏障 | P1/P2;前端状态未知 | + +五个假设的因果状态与原产品行为修复资格当前均为 `INCONCLUSIVE`。本次尚未取得上一轮 P1–P6 的任何专用记录,不能将用户复现反馈冒充为这些探针的完整运行,更不能声称某个锁环已确认。 + +## 用户后续澄清:主窗口路径异常,业务后端仍可工作 + +**以此澄清覆盖本报告早期“全后端操作停滞”的范围假设。** 用户确认:异常时主窗口像连接不到后端,但语音输入和选区助手使用正常。不能继续将本轮描述成整个后端失活;也不能未经观察就断言 IPC 物理断连。日志 L12199–12204 的选区捕获与 L12217–12226 的普通听写局部完成与此相容,但缺少故障时间关联,不能替代用户的同时性观察。 + +更新契约:N1 对语音/选区的业务链路在该用户报告窗口为 NO_VIOLATION_OBSERVED;N2/N3/N6 的主窗口请求/设置可见结果由用户报告 VIOLATION,具体发送/执行/返回边界仍 INSUFFICIENT_EVIDENCE;N4 本次是否托盘仍无法退出没有新确认。旧 H1–H5 保留为 A0 历史,不以“无NDJSON”强判其成立或否定。 + +本轮活动假设(共四个,不把历史五项当作本轮已确认原因): + +| ID | Hypothesis | Falsifiable prediction | If true, what change would make the bug disappear? | Probe IDs | +|---|---|---|---|---| +| R1-H1 | 主窗口发起的 IPC 已进入/完成后端,但结果未被该页面应用,其他窗口/原生触发不受影响(N2/N3/N5) | 固定分类的 main webview 请求进入与handler返回存在,主窗口Promise/结果应用仍未完成;需前端现场补证,不能把dispatch返回当response到达 | 只修证实的主窗口响应/页面生命周期边界,保留命令权限和取消语义 | P1/P2,前端交付未知 | +| R1-H2 | 主窗口 settings snapshot/readRequest 或 persistQueue 的在途请求未完成,后续设置UI被串行队列连带卡住(N2/N6) | 对应get_settings_snapshot/update_setting_fields/set_settings有进入未返回或页面读写队列pending;独立业务正常 | 只修证实的单个在途设置阶段,不通过清空队列/超时重放掩盖未提交或已提交结果 | P1/P2/P3/P4 | +| R1-H3 | QA/选区快捷键保存或注册等待共享settings_host_gate/主线程ACK,仅阻塞该设置事务而非整个业务后端(N2/N6) | 对应命令/host gate/派发/注册的生成操作链明确停滞,原语音与选区工作作为对照 | 只修证实的事务等待边界或锁关系,保持热键冲突检查、原监听回滚和按键顺序 | P1/P2/P4 | +| R1-H4 | 主窗口页面重载/新实例的readiness或IPC桥状态异常,旧业务链路仍运行(N2/N3/N5) | 需要真实页面实例/ready pending或rejected证据;已fulfilled的同一Promise不会自行再pending,不能把后期异常归因初次启动 | 只修证实的页面/握手生命周期错误,不绕过fail-closed启动契约或加盲目重试 | P1/P2;frontend未知需补证 | + +以上状态与原产品修复资格仍均为 INCONCLUSIVE。已源码核对 `HotkeySettingsContext.tsx:42–68,127–140` 存在读请求复用与串行写队列;`shared.ts:46–63,77–94` 复用readiness;`App.tsx:98–120` 等待初始握手后才挂业务UI。因此后期卡住不能无证据归结为同一已完成的readiness。下一步最小观察扩展是区分 main/other 的真实native invoke来源、补 get_settings_snapshot/update_setting_fields/快捷键设置与共享host gate阶段,不改变业务调度。 + +## 可执行的诊断交付修复与最小观察扩展 + +`commands/misc.rs:266–289` 的软件内 `export_error_log` 仅复制 `openless.log`;A0 专用 writer 独立追加 NDJSON。该实现和用户本次导出路径直接解释为何附件不含专用证据。因此**诊断交付缺口可先修复**,但它不是原卡死根因。 + +交付假设分开判定:D1=内置导出未包含专用源(SOURCE-CONFIRMED/ACTIONABLE);D2=现场未安装/未启用诊断构建(INCONCLUSIVE);D3=writer 未初始化、写入失败或证据丢失(INCONCLUSIVE)。修补后必须让单一导出文件携带当前诊断会话、真实捕获健康、前端 unknown 与尾部 unknown;缺失/失败必须显式呈现,不得拿旧文件或零 drops 伪装完整捕获。 + +补观察仍限定原六类边界,优先扩展 P1/P2 的快捷键录制/设置/选区助手与 QA 请求及 P4 原生注册/派发边界;不添加 P7,不做无限量通用日志。只记录闭合类别、结果、时长、生成操作标签,禁止按键原值、选区文本、配置/凭据、窗口名及原始错误。保留原探针,运行内关联必须跟随实际操作,不能用最近 UI 状态或同线程邻近记录补关联。 + +下一步:源码核对 H4 最小直接路径;如仍无 ACTIONABLE 原行为修复,明确保留该阻塞并请求现有专用文件(不先要求用户重新复现),同时修复诊断导出并交付新增边界观察的包。不能将“日志更完整/构建通过”宣告为原卡死已修复。 diff --git a/openless-all/app/scripts/test-backend-hang-diagnostics.py b/openless-all/app/scripts/test-backend-hang-diagnostics.py index 889252374..e711c8155 100644 --- a/openless-all/app/scripts/test-backend-hang-diagnostics.py +++ b/openless-all/app/scripts/test-backend-hang-diagnostics.py @@ -11,6 +11,21 @@ import tempfile source = Path(__file__).resolve().parents[1] / "src-tauri/src/issue1338_debug.rs" +# Source-contract layer only: the Windows diagnostic command is asynchronous; +# standard builds still use the original synchronous copy/Android SAF branch. +# Runtime behavior is covered separately by the injected Rust module tests. +misc = (source.parent / "commands/misc.rs").read_text(encoding="utf-8").replace("\r\n", "\n") +start = misc.index('#[cfg(all(target_os = "windows", feature = "issue-1338-diagnostics"))]\n#[tauri::command]\npub async fn export_error_log(') +standard = misc.index('#[cfg(not(all(target_os = "windows", feature = "issue-1338-diagnostics")))]\n#[tauri::command]\npub fn export_error_log(', start) +end = misc.index('/// Android:直接把当前会话日志写入公共 Downloads', standard) +diagnostic_export = misc[start:standard] +standard_export = misc[standard:end] +assert 'spawn_blocking(move ||' in diagnostic_export +assert 'crate::issue1338_debug::export_log(' in diagnostic_export +assert 'std::fs::copy' not in diagnostic_export +assert 'std::fs::copy(&src, std::path::Path::new(&target_path))' in standard_export +assert 'write_content_uri(&target_path, &bytes)' in standard_export +assert 'issue1338_debug' not in standard_export.split('pub fn export_error_log(', 1)[1] with tempfile.TemporaryDirectory(prefix="openless-backend-hang-tests-") as directory: root = Path(directory) (root / "src/bin").mkdir(parents=True) @@ -24,6 +39,7 @@ parking_lot = "0.12" serde = { version = "1", features = ["derive"] } serde_json = "1" +tempfile = "3" ''', encoding="utf-8") module = "#[path = " + json.dumps(str(source), ensure_ascii=False) + "]\nmod issue1338_debug;\n" (root / "src/lib.rs").write_text(module, encoding="utf-8") diff --git a/openless-all/app/scripts/test-recorder-module.py b/openless-all/app/scripts/test-recorder-module.py index 749adedb0..ce24f6937 100644 --- a/openless-all/app/scripts/test-recorder-module.py +++ b/openless-all/app/scripts/test-recorder-module.py @@ -27,6 +27,7 @@ parking_lot = "0.12" serde = { version = "1", features = ["derive"] } serde_json = "1" +tempfile = "3" thiserror = "1" log = "0.4" """ + "cpal = { version = \"=0.15.3\", path = " + json.dumps((recorder.parents[1] / "vendor/cpal").as_posix()) + " }\n" diff --git a/openless-all/app/src-tauri/src/commands/hotkeys.rs b/openless-all/app/src-tauri/src/commands/hotkeys.rs index 3bd555ac8..f8cec938e 100644 --- a/openless-all/app/src-tauri/src/commands/hotkeys.rs +++ b/openless-all/app/src-tauri/src/commands/hotkeys.rs @@ -106,6 +106,10 @@ pub fn set_selection_polish_hotkey( coord: CoordinatorState<'_>, binding: Option, ) -> Result<(), String> { + use crate::issue1338_debug::{Boundary, Event, Kind, Outcome, Probe, SyncContext}; + let boundary = Boundary::enter(Probe::P2, Kind::SelectionHotkeySettings); + let _context = SyncContext::enter(boundary.operation); + let result = (|| { if let Some(binding) = binding.as_ref() { crate::shortcut_binding::validate_binding(binding).map_err(|e| e.to_string())?; crate::shortcut_binding::reject_side_specific_non_dictation(binding)?; @@ -115,6 +119,9 @@ pub fn set_selection_polish_hotkey( next.selection_polish_hotkey = binding; reject_hotkey_collisions(&next)?; super::settings::persist_strict_settings(&coord, next) + })(); + boundary.record(Event::Returned, if result.is_ok() { Outcome::Ok } else { Outcome::Error }); + result } /// Replaces the whole style-pack direct-hotkey table (issue #759). The frontend sends the full list on any diff --git a/openless-all/app/src-tauri/src/commands/misc.rs b/openless-all/app/src-tauri/src/commands/misc.rs index a722dfbc2..ab3ead231 100644 --- a/openless-all/app/src-tauri/src/commands/misc.rs +++ b/openless-all/app/src-tauri/src/commands/misc.rs @@ -82,24 +82,39 @@ pub async fn get_hotkey_status(core: CoreState<'_>) -> Result) -> HotkeyCapability { + use crate::issue1338_debug::{Boundary, Kind, Probe, SyncContext}; + let boundary = Boundary::enter(Probe::P2, Kind::GetHotkeyCapability); + let _context = SyncContext::enter(boundary.operation); #[cfg(mobile)] { let _ = coord; return HotkeyCapability::current(); } #[cfg(not(mobile))] - coord.hotkey_capability() + { + let result = coord.hotkey_capability(); + boundary.returned(); + result + } } #[tauri::command] pub fn set_shortcut_recording_active(coord: CoordinatorState<'_>, active: bool) { + use crate::issue1338_debug::{Boundary, Event, Facts, Kind, Outcome, Probe, SyncContext}; + let boundary = Boundary::enter(Probe::P2, Kind::ShortcutRecording); + let _context = SyncContext::enter(boundary.operation); + boundary.operation.record(Event::Requested, Outcome::Observed, + Facts { kind: Kind::ShortcutRecording, flag: active, ..Default::default() }); #[cfg(mobile)] { let _ = (coord, active); return; } #[cfg(not(mobile))] - coord.set_shortcut_recording_active(active); + { + coord.set_shortcut_recording_active(active); + boundary.returned(); + } } #[tauri::command] @@ -262,6 +277,16 @@ pub async fn stop_microphone_level_monitor(app: AppHandle) { /// On Android the dialog returns a `content://` URI which `std::fs::copy` cannot handle; write /// through the JNI ContentResolver instead, avoiding the 0-byte files caused by /// tauri-plugin-fs's detachFd. +#[cfg(all(target_os = "windows", feature = "issue-1338-diagnostics"))] +#[tauri::command] +pub async fn export_error_log(target_path: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let src = resolve_openless_log_path()?; + crate::issue1338_debug::export_log(&src, std::path::Path::new(&target_path)) + }).await.map_err(|_| "issue1338_export_worker_failed: capture unknown".to_owned())? +} + +#[cfg(not(all(target_os = "windows", feature = "issue-1338-diagnostics")))] #[tauri::command] pub fn export_error_log(target_path: String) -> Result<(), String> { let src = resolve_openless_log_path()?; diff --git a/openless-all/app/src-tauri/src/commands/qa.rs b/openless-all/app/src-tauri/src/commands/qa.rs index b4e081e8d..51fb1d8cb 100644 --- a/openless-all/app/src-tauri/src/commands/qa.rs +++ b/openless-all/app/src-tauri/src/commands/qa.rs @@ -20,6 +20,10 @@ pub fn set_qa_hotkey( coord: CoordinatorState<'_>, binding: Option, ) -> Result<(), String> { + use crate::issue1338_debug::{Boundary, Event, Kind, Outcome, Probe, SyncContext}; + let boundary = Boundary::enter(Probe::P2, Kind::QaHotkeySettings); + let _context = SyncContext::enter(boundary.operation); + let result = (|| { if binding .as_ref() .is_some_and(|binding| !binding.modifiers.is_empty()) @@ -31,6 +35,9 @@ pub fn set_qa_hotkey( prefs.qa_companion_key = binding.map(|binding| binding.primary).unwrap_or_default(); reject_hotkey_collisions(&prefs)?; super::settings::persist_strict_settings(&coord, prefs) + })(); + boundary.record(Event::Returned, if result.is_ok() { Outcome::Ok } else { Outcome::Error }); + result } /// User clicks ✕ or presses Esc to close the QA panel. diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 69c1aa681..51c8f8a2d 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -326,6 +326,7 @@ pub async fn set_settings( ) -> Result { // Capture old values for the remote-input service diff (start/stop/restart when the // port/switch changes after persist). + crate::issue1338_debug::observe_future(crate::issue1338_debug::Kind::SetSettings, async move { let remote_prev = coord.backend().get_preferences(); if let Some(edits) = &edits { prefs = openless_core::preference_patch::patch_preferences(&prefs, edits) @@ -399,6 +400,7 @@ pub async fn set_settings( .map_err(|error| error.message)?; } Ok(prefs) + }).await } #[cfg(mobile)] @@ -948,14 +950,30 @@ pub struct SettingsSnapshot { #[tauri::command] pub fn get_settings_snapshot(core: CoreState<'_>) -> Result { - settings_snapshot(&core) + use crate::issue1338_debug::{Boundary, Event, Kind, Outcome, Probe, SyncContext}; + let boundary = Boundary::enter(Probe::P2, Kind::SettingsSnapshot); + let _context = SyncContext::enter(boundary.operation); + let result = settings_snapshot(&core); + boundary.record(Event::Returned, if result.is_ok() { Outcome::Ok } else { Outcome::Error }); + result } fn settings_snapshot(backend: &openless_core::OpenLessBackend) -> Result { for _ in 0..3 { + use crate::issue1338_debug::{Boundary, Kind, Probe}; + let state_read = Boundary::enter(Probe::P2, Kind::SnapshotRevision); let revision = backend.snapshot().preferences_revision; + state_read.returned(); + drop(state_read); + let prefs_read = Boundary::enter(Probe::P2, Kind::SnapshotPreferences); let preferences = backend.get_preferences(); - if backend.snapshot().preferences_revision == revision { + prefs_read.returned(); + drop(prefs_read); + let verify = Boundary::enter(Probe::P2, Kind::SnapshotVerify); + let matches = backend.snapshot().preferences_revision == revision; + verify.returned(); + drop(verify); + if matches { return Ok(SettingsSnapshot { preferences, revision, @@ -998,9 +1016,11 @@ pub async fn update_setting_fields( app: AppHandle, edits: std::collections::BTreeMap, ) -> Result { + crate::issue1338_debug::observe_future(crate::issue1338_debug::Kind::UpdateSettingFields, async move { let prefs = coord.backend().get_preferences(); set_settings(coord.clone(), app, prefs, Some(edits)).await?; settings_snapshot(&coord.backend()) + }).await } #[cfg(mobile)] diff --git a/openless-all/app/src-tauri/src/issue1266_debug.rs b/openless-all/app/src-tauri/src/issue1266_debug.rs index 5c53240b9..edae44d7e 100644 --- a/openless-all/app/src-tauri/src/issue1266_debug.rs +++ b/openless-all/app/src-tauri/src/issue1266_debug.rs @@ -178,7 +178,9 @@ pub(crate) struct HostGuard<'a> { operation: Operation, kind: Kind, } -#[cfg(not(all(target_os = "windows", feature = "issue-1266-diagnostics")))] +#[cfg(all(target_os = "windows", feature = "issue-1338-diagnostics", not(feature = "issue-1266-diagnostics")))] +pub(crate) type HostGuard<'a> = crate::issue1338_debug::Guard<'a, ()>; +#[cfg(not(all(target_os = "windows", any(feature = "issue-1266-diagnostics", feature = "issue-1338-diagnostics"))))] pub(crate) type HostGuard<'a> = parking_lot::MutexGuard<'a, ()>; pub(crate) fn lock_host(mutex: &parking_lot::Mutex<()>, kind: Kind) -> HostGuard<'_> { @@ -190,7 +192,20 @@ pub(crate) fn lock_host(mutex: &parking_lot::Mutex<()>, kind: Kind) -> HostGuard operation.record(Event::Acquired, Outcome::Observed, Facts { kind, ..Facts::default() }); HostGuard { guard: Some(guard), operation, kind } } - #[cfg(not(all(target_os = "windows", feature = "issue-1266-diagnostics")))] + // [DEBUG-ISSUE-1338] Observe the same gate for commands and registration + // supervisors without changing ownership, lock ordering, or retry policy. + #[cfg(all(target_os = "windows", feature = "issue-1338-diagnostics", not(feature = "issue-1266-diagnostics")))] + { + let trace_kind = match kind { + Kind::Settings => crate::issue1338_debug::Kind::SettingsHostGate, + Kind::SelectionHotkey => crate::issue1338_debug::Kind::SelectionHotkeyRegistration, + Kind::QaHotkey => crate::issue1338_debug::Kind::QaHotkeyRegistration, + _ => crate::issue1338_debug::Kind::OtherHotkeyRegistration, + }; + crate::issue1338_debug::lock_at(mutex, crate::issue1338_debug::Probe::P2, + trace_kind) + } + #[cfg(not(all(target_os = "windows", any(feature = "issue-1266-diagnostics", feature = "issue-1338-diagnostics"))))] { let _ = kind; mutex.lock() } } diff --git a/openless-all/app/src-tauri/src/issue1338_debug.rs b/openless-all/app/src-tauri/src/issue1338_debug.rs index 7566a45d7..b9c097aed 100644 --- a/openless-all/app/src-tauri/src/issue1338_debug.rs +++ b/openless-all/app/src-tauri/src/issue1338_debug.rs @@ -1,4 +1,4 @@ -//! [DEBUG-ISSUE-1338] A0 observations only, not a behavioral fix. +//! [DEBUG-ISSUE-1338] A1 diagnostic delivery repair, not a confirmed stall fix. //! Fixed closed facts only. Producers never serialize, write, initialize a sink, //! acquire application locks, or wait for queue capacity. try_send is not lock-free. //! Missing records and the final flush tail remain unknown, including forced exit. @@ -9,8 +9,8 @@ //! P5 covers exported native checks, not every internal platform helper. #![allow(dead_code)] use serde::Serialize; -pub(crate) const ATTEMPT: &str = "A0-observation"; -pub(crate) const CHANGE: &str = "no-behavior-change"; +pub(crate) const ATTEMPT: &str = "A1-diagnostic-delivery"; +pub(crate) const CHANGE: &str = "C1-single-file-diagnostic-export"; pub(crate) const SESSION: &str = "issue-1338-20261106"; pub(crate) const LOG_NAME: &str = "openless-issue-1338-20261106-session.ndjson"; pub(crate) const fn enabled() -> bool { @@ -21,8 +21,9 @@ pub(crate) enum Probe { P1, P2, P3, P4, P5, P6 } impl Probe { fn hypotheses(self) -> &'static [&'static str] { match self { - Self::P1 | Self::P4 | Self::P6 => &["H1", "H2", "H4", "H5"], - Self::P2 => &["H1", "H2", "H3", "H5"], Self::P3 => &["H3"], Self::P5 => &["H2"], + Self::P1 | Self::P4 | Self::P6 => &["R1-H1", "R1-H2", "R1-H3", "R1-H4"], + Self::P2 => &["R1-H1", "R1-H2", "R1-H3", "R1-H4"], + Self::P3 => &["R1-H2"], Self::P5 => &["R1-H1"], } } } @@ -45,12 +46,24 @@ pub(crate) enum Kind { GetSettings, ListChannels, ReadCredential, Accessibility, Microphone, StartupSnapshot, PrefsRead, WarmConfigure, CredentialWorker, CredentialsLock, CacheLock, HostDispatch, CapsuleDispatch, TrayQuit, QuitRequest, + SettingsSnapshot, UpdateSettingFields, SetSettings, QaHotkeySettings, + SelectionHotkeySettings, GetHotkeyCapability, ShortcutRecording, + SettingsHostGate, SnapshotRevision, SnapshotPreferences, SnapshotVerify, + SettingsPersist, SettingsRefresh, + SelectionHotkeyRegistration, QaHotkeyRegistration, OtherHotkeyRegistration, } impl Kind { // Never pass through arbitrary command names, arguments, labels or payloads. pub(crate) fn command(command: &str) -> Option { match command { "get_settings" => Some(Self::GetSettings), + "get_settings_snapshot" => Some(Self::SettingsSnapshot), + "update_setting_fields" => Some(Self::UpdateSettingFields), + "set_settings" => Some(Self::SetSettings), + "set_qa_hotkey" => Some(Self::QaHotkeySettings), + "set_selection_polish_hotkey" => Some(Self::SelectionHotkeySettings), + "get_hotkey_capability" => Some(Self::GetHotkeyCapability), + "set_shortcut_recording_active" => Some(Self::ShortcutRecording), "get_startup_snapshot" => Some(Self::StartupSnapshot), "list_channels" => Some(Self::ListChannels), "read_credential" => Some(Self::ReadCredential), @@ -60,8 +73,17 @@ impl Kind { } } } +/// Fixed source category, never an arbitrary webview label or page identity. +#[derive(Clone, Copy, Debug, Default, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum WebviewKind { Main, Other, #[default] Unknown } +impl WebviewKind { + pub(crate) fn from_label(label: &str) -> Self { + if label == "main" { Self::Main } else { Self::Other } + } +} #[derive(Clone, Copy, Debug, Default, Serialize)] -pub(crate) struct Facts { pub kind: Kind, pub value: u64, pub flag: bool } +pub(crate) struct Facts { pub kind: Kind, pub value: u64, pub flag: bool, pub webview: WebviewKind } #[derive(Clone, Copy, Debug)] pub(crate) struct Operation { id: u64, parent: u64, probe: Probe } impl Operation { @@ -169,20 +191,29 @@ impl MainDispatch { #[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] pub(crate) struct Guard<'a, T: ?Sized> { guard: Option>, boundary: Boundary, + // Only the synchronous host-gate scope installs context for its real + // nested main-thread dispatches. MutexGuard and SyncContext are !Send. + _context: Option, } #[cfg(not(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics"))))] pub(crate) type Guard<'a, T> = parking_lot::MutexGuard<'a, T>; pub(crate) fn lock(mutex: &parking_lot::Mutex, kind: Kind) -> Guard<'_, T> { + lock_at(mutex, Probe::P3, kind) +} +pub(crate) fn lock_at(mutex: &parking_lot::Mutex, probe: Probe, kind: Kind) -> Guard<'_, T> { #[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] { - let boundary = Boundary { operation: Operation::new(Probe::P3), kind }; + let boundary = Boundary { operation: Operation::new(probe), kind }; boundary.record(Event::Requested, Outcome::Observed); let guard = mutex.lock(); boundary.record(Event::Acquired, Outcome::Observed); - Guard { guard: Some(guard), boundary } + let context = matches!(kind, Kind::SettingsHostGate | Kind::SelectionHotkeyRegistration + | Kind::QaHotkeyRegistration | Kind::OtherHotkeyRegistration) + .then(|| SyncContext::enter(boundary.operation)); + Guard { guard: Some(guard), boundary, _context: context } } #[cfg(not(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics"))))] - { let _ = kind; mutex.lock() } + { let _ = (probe, kind); mutex.lock() } } #[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] impl std::ops::Deref for Guard<'_, T> { @@ -210,6 +241,14 @@ pub(crate) fn build_id() -> &'static str { else { "unbound" } } +#[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] +#[path = "issue1338_export.rs"] +mod export; +#[cfg(all(target_os = "windows", feature = "issue-1338-diagnostics"))] +pub(crate) fn export_log(source: &std::path::Path, target: &std::path::Path) -> Result<(), String> { + export::export_log(source, target).map_err(str::to_owned) +} + #[derive(Default, Serialize)] pub(crate) struct Health { enabled: bool, @@ -292,8 +331,13 @@ mod sink { thread_label: u64, } + #[derive(Debug)] + enum Message { + Record(Record), + Snapshot(SyncSender>), + } pub(super) struct Sink { - sender: SyncSender, + sender: SyncSender, pub(super) health: Arc, started: Instant, pub(super) run: u128, @@ -307,7 +351,7 @@ mod sink { fn submit(&self, record: Record) { #[cfg(test)] INSPECT.with(|hook| { if let Some(inspect) = hook.borrow().as_ref() { inspect(&record); } }); - match self.sender.try_send(record) { + match self.sender.try_send(Message::Record(record)) { Ok(()) => {}, Err(TrySendError::Full(_)) => { self.health.dropped.fetch_add(1, Relaxed); }, Err(TrySendError::Disconnected(_)) => { @@ -318,10 +362,14 @@ mod sink { } } + fn next_sequence(sequences: &[AtomicU64; 6], probe: Probe) -> u64 { + sequences[probe as usize].fetch_add(1, Relaxed) + } + fn submit(sink: &Sink, operation: Operation, event: Event, outcome: Outcome, facts: Facts) { sink.submit(Record { operation, event, outcome, facts, - seq: SEQUENCES[operation.probe as usize].fetch_add(1, Relaxed), + seq: next_sequence(&SEQUENCES, operation.probe), elapsed_us: sink.started.elapsed().as_micros().min(u64::MAX as u128) as u64, thread_label: thread_label(), }); @@ -391,27 +439,101 @@ mod sink { Ok(line) } - fn write_record(writer: &mut impl Write, record: Record, run: u128, start: u128, - health: &HealthState) -> io::Result<()> { - writer.write_all(&encode(record, run, start, health)?)?; - health.written.fetch_add(1, Relaxed); + // Only exports wait, on a blocking worker, with a finite failure boundary. + // Probe producers retain bounded try_send and never wait for this handshake. + pub(super) fn snapshot() -> Result { + request_snapshot(SINK.get().ok_or("issue1338_no_writer: capture unknown")?) + } + fn request_snapshot(sink: &Sink) -> Result { + let (tx, rx) = sync_channel(1); + sink.sender.try_send(Message::Snapshot(tx)) + .map_err(|_| "issue1338_checkpoint_unavailable: capture unknown")?; + rx.recv_timeout(std::time::Duration::from_secs(3)) + .map_err(|_| "issue1338_checkpoint_unacknowledged: capture unknown")? + } + + fn checkpoint(writer: &mut impl Write, run: u128, offset: u64, + health: &HealthState) -> io::Result { + let mut state = health.snapshot_value(); + state.run_id = Some(format!("r{run:x}")); + let incomplete = state.dropped != 0 || state.queue_closed != 0 || state.io_failed || state.spawn_failed; + let mut line = serde_json::to_vec(&serde_json::json!({ + "schema": "issue1338-export-checkpoint.v1", "session_id": SESSION, + "attempt_id": ATTEMPT, "change_id": CHANGE, + "checkpoint_start_byte": offset, + "capture_unix_ms": SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_millis()), + "run_id": state.run_id, "build_id": build_id(), "feature_enabled": enabled(), + "capture_health": state, + "counter_scope": "current_backend_run_before_checkpoint", + "record_counters": "probe_records_only_excludes_export_checkpoints", + "backend_preinitialization_capture": "unknown", + "checkpoint_commit": "ack_only_after_write_and_flush", + "historical_runs": "included_not_current_coverage_unknown", + "frontend_coverage": { "scope": "backend_only", "request_intent": "unknown", + "request_send": "unknown", "response_application": "unknown", "transport_loss": "unknown" }, + "tail_capture": "unknown", "flush_durability": "os_not_fsync", + "coverage": if incomplete { "incomplete" } else { "backend_checkpoint_only_not_complete_capture" } + }))?; + line.push(b'\n'); + writer.write_all(&line)?; writer.flush()?; - health.flushed.fetch_add(1, Relaxed); - Ok(()) + let end = offset.checked_add(line.len() as u64).ok_or_else(|| io::Error::other("offset"))?; + Ok(super::export::Snapshot { end, checkpoint: line, incomplete }) } - fn drain(mut writer: impl Write, rx: Receiver, run: u128, start: u128, + fn drain(writer: impl Write, rx: Receiver, run: u128, start: u128, health: &HealthState) { + drain_at(writer, rx, run, start, health, 0); + } + fn drain_at(mut writer: impl Write, rx: Receiver, run: u128, start: u128, + health: &HealthState, mut offset: u64) { health.ready.store(true, Relaxed); - for record in rx { - if write_record(&mut writer, record, run, start, health).is_err() { - health.io_failed.store(true, Relaxed); - break; - } + for message in rx { + let result = match message { + Message::Record(record) => (|| -> io::Result<()> { + let line = encode(record, run, start, health)?; + writer.write_all(&line)?; + health.written.fetch_add(1, Relaxed); + writer.flush()?; + health.flushed.fetch_add(1, Relaxed); + offset = offset.checked_add(line.len() as u64).ok_or_else(|| io::Error::other("offset"))?; + Ok(()) + })(), + Message::Snapshot(reply) => match checkpoint(&mut writer, run, offset, health) { + Ok(snapshot) => { + offset = snapshot.end; + let _ = reply.try_send(Ok(snapshot)); + Ok(()) + }, + Err(error) => { + let _ = reply.try_send(Err("issue1338_checkpoint_io_failed: capture unknown")); + Err(error) + } + }, + }; + if result.is_err() { health.io_failed.store(true, Relaxed); break; } } health.ready.store(false, Relaxed); } + #[cfg(test)] + pub(super) fn export_fixture() -> (Vec, super::export::Snapshot) { + let (tx, rx) = sync_channel(2); + let (reply, ack) = sync_channel(1); + tx.send(Message::Record(Record { + operation: Operation { id: 1, parent: 0, probe: Probe::P2 }, + event: Event::Entered, outcome: Outcome::Observed, facts: Facts::default(), + seq: 0, elapsed_us: 0, thread_label: 1, + })).unwrap(); + tx.send(Message::Snapshot(reply)).unwrap(); + drop(tx); + let health = HealthState::default(); + health.initialized.store(true, Relaxed); + let mut bytes = Vec::new(); + drain(&mut bytes, rx, 0x1338, 0, &health); + (bytes, ack.try_recv().unwrap().unwrap()) + } + #[cfg(all(target_os = "windows", feature = "issue-1338-diagnostics", not(test)))] pub(super) fn initialize() { SINK.get_or_init(|| { @@ -432,7 +554,10 @@ mod sink { std::fs::OpenOptions::new().create(true).append(true).open(directory.join(LOG_NAME)) })(); match file { - Ok(file) => drain(file, receiver, now.as_nanos(), now.as_millis(), &writer_health), + Ok(file) => match file.metadata() { + Ok(meta) => drain_at(file, receiver, now.as_nanos(), now.as_millis(), &writer_health, meta.len()), + Err(_) => { writer_health.io_failed.store(true, Relaxed); }, + }, Err(_) => { writer_health.io_failed.store(true, Relaxed); }, } }).is_err() { health.spawn_failed.store(true, Relaxed); } @@ -490,7 +615,7 @@ mod sink { fn sample() -> Record { Record { operation: Operation { id: 2, parent: 1, probe: Probe::P5 }, event: Event::ScopeLeft, outcome: Outcome::Observed, - facts: Facts { kind: Kind::Microphone, value: 0, flag: false }, seq: 4, elapsed_us: 1234, thread_label: 7 } + facts: Facts { kind: Kind::Microphone, value: 0, flag: false, ..Default::default() }, seq: 4, elapsed_us: 1234, thread_label: 7 } } #[test] fn schema_is_typed_single_line_and_scope_left_is_not_success() { @@ -501,19 +626,20 @@ mod sink { assert!(value.get(key).is_some(), "missing closed schema field"); } assert_eq!(value["session_id"], SESSION); - assert_eq!(value["attempt_id"], "A0-observation"); - assert_eq!(value["change_id"], "no-behavior-change"); + assert_eq!(value["attempt_id"], ATTEMPT); + assert_eq!(value["change_id"], CHANGE); assert_eq!(value["schema"], "diagnosing-bugs.v1"); assert_eq!(value["probe_id"], "P5"); - assert_eq!(value["hypothesis_ids"], serde_json::json!(["H2"])); + assert_eq!(value["hypothesis_ids"], serde_json::json!(["R1-H1"])); assert_eq!(value["event"], "scope_left"); assert_eq!(value["outcome"], "observed"); assert_eq!(value["parent_operation_id"], 1); assert_eq!(value["event_seq"], 4); assert_eq!(value["thread_label"], 7); let safe = value["safe"].as_object().unwrap(); - assert_eq!(safe.keys().map(String::as_str).collect::>(), ["flag", "kind", "value"]); - assert!(safe.values().all(|v| v.is_boolean() || v.is_number() || v == "microphone")); + assert_eq!(safe, serde_json::json!({ + "flag": false, "kind": "microphone", "value": 0, "webview": "unknown" + }).as_object().unwrap()); } #[test] fn full_and_disconnected_are_counted_without_waiting() { @@ -537,7 +663,7 @@ mod sink { #[test] fn write_failure_does_not_claim_a_written_or_flushed_record() { let (tx, rx) = sync_channel(1); - tx.send(sample()).unwrap(); drop(tx); + tx.send(Message::Record(sample())).unwrap(); drop(tx); let health = HealthState::default(); drain(FailWrite, rx, 1, 2, &health); assert!(health.io_failed.load(Relaxed)); @@ -553,7 +679,7 @@ mod sink { #[test] fn io_failure_retains_independent_write_and_flush_watermarks() { let (tx, rx) = sync_channel(1); - tx.send(sample()).unwrap(); drop(tx); + tx.send(Message::Record(sample())).unwrap(); drop(tx); let health = HealthState::default(); drain(FailFlush(Vec::new()), rx, 1, 2, &health); assert!(health.io_failed.load(Relaxed)); @@ -564,7 +690,7 @@ mod sink { #[test] fn successful_writer_records_every_complete_line() { let (tx, rx) = sync_channel(2); - tx.send(sample()).unwrap(); tx.send(sample()).unwrap(); drop(tx); + tx.send(Message::Record(sample())).unwrap(); tx.send(Message::Record(sample())).unwrap(); drop(tx); let health = HealthState::default(); let mut bytes = Vec::new(); drain(&mut bytes, rx, 1, 2, &health); @@ -572,10 +698,96 @@ mod sink { assert_eq!(bytes.split(|b| *b == b'\n').filter(|line| !line.is_empty()).count(), 2); } #[test] + fn current_checkpoint_is_serialized_by_writer_and_acknowledged_only_after_flush() { + let (tx, rx) = sync_channel(2); + let (reply, ack) = sync_channel(1); + tx.send(Message::Record(sample())).unwrap(); + tx.send(Message::Snapshot(reply)).unwrap(); + drop(tx); + let health = HealthState::default(); + health.initialized.store(true, Relaxed); + let mut bytes = b"historical\n".to_vec(); + let initial = bytes.len() as u64; + drain_at(&mut bytes, rx, 0xab, 0, &health, initial); + let snapshot = ack.try_recv().unwrap().unwrap(); + assert_eq!(snapshot.end, bytes.len() as u64); + assert!(bytes.ends_with(&snapshot.checkpoint)); + assert!(!snapshot.incomplete); + let value: serde_json::Value = serde_json::from_slice(&snapshot.checkpoint).unwrap(); + assert_eq!(value["run_id"], "rab"); + assert_eq!(value["capture_health"]["run_id"], "rab"); + assert_eq!(value["capture_health"]["written"], 1); + assert_eq!(value["capture_health"]["flushed"], 1); + assert_eq!(value["capture_health"]["dropped"], 0); + assert_eq!(value["capture_health"]["ready"], true); + assert_eq!(value["feature_enabled"], enabled()); + assert_eq!(value["build_id"], build_id()); + assert_eq!(value["tail_capture"], "unknown"); + assert_eq!(value["frontend_coverage"], serde_json::json!({ + "scope": "backend_only", "request_intent": "unknown", "request_send": "unknown", + "response_application": "unknown", "transport_loss": "unknown" + })); + assert_eq!(value["historical_runs"], "included_not_current_coverage_unknown"); + assert_eq!(value["backend_preinitialization_capture"], "unknown"); + } + #[test] + fn checkpoint_write_and_flush_failure_never_acknowledge_an_old_snapshot() { + fn run(writer: impl Write) { + let (tx, rx) = sync_channel(1); + let (reply, ack) = sync_channel(1); + tx.send(Message::Snapshot(reply)).unwrap(); + drop(tx); + let health = HealthState::default(); + drain(writer, rx, 2, 0, &health); + assert!(ack.try_recv().unwrap().is_err()); + assert!(health.io_failed.load(Relaxed)); + } + run(FailWrite); + run(FailFlush(b"old snapshot\n".to_vec())); + } + #[test] + fn record_failure_before_checkpoint_cannot_authorize_readable_old_file() { + let (tx, rx) = sync_channel(2); + let (reply, ack) = sync_channel(1); + tx.send(Message::Record(sample())).unwrap(); + tx.send(Message::Snapshot(reply)).unwrap(); + drop(tx); + let health = HealthState::default(); + drain(FailFlush(b"old snapshot\n".to_vec()), rx, 2, 0, &health); + assert!(ack.try_recv().is_err()); + assert!(health.io_failed.load(Relaxed)); + } + #[test] + fn unavailable_writer_and_full_or_closed_queue_are_explicit_failures() { + assert!(snapshot().is_err()); // initialize() under tests never initializes SINK. + let (sender, receiver) = sync_channel(1); + let sink = Sink { sender, health: Arc::default(), started: Instant::now(), run: 9 }; + sink.submit(sample()); + assert!(request_snapshot(&sink).is_err()); // no waiting on full queue + drop(receiver); + assert!(request_snapshot(&sink).is_err()); + } + #[test] + fn writer_checkpoint_preserves_nonzero_loss_instead_of_claiming_complete() { + let health = HealthState::default(); + health.dropped.store(1, Relaxed); + let snapshot = checkpoint(&mut Vec::new(), 7, 0, &health).unwrap(); + assert!(snapshot.incomplete); + let value: serde_json::Value = serde_json::from_slice(&snapshot.checkpoint).unwrap(); + assert_eq!(value["coverage"], "incomplete"); + assert_eq!(value["capture_health"]["dropped"], 1); + } + #[test] fn probe_sources_have_independent_sequences() { - let first = SEQUENCES[0].fetch_add(1, Relaxed); - SEQUENCES[1].fetch_add(1, Relaxed); - assert_eq!(SEQUENCES[0].fetch_add(1, Relaxed), first + 1); + // Use the real producer helper with private counters: other tests + // deliberately submit records concurrently through global SEQUENCES. + let sequences = [const { AtomicU64::new(0) }; 6]; + let first = next_sequence(&sequences, Probe::P1); + assert_eq!(first, 0); + assert_eq!(next_sequence(&sequences, Probe::P2), 0); + assert_eq!(next_sequence(&sequences, Probe::P1), first + 1); + assert_eq!(next_sequence(&sequences, Probe::P2), 1); + assert!(sequences[2..].iter().all(|sequence| sequence.load(Relaxed) == 0)); } } } @@ -598,13 +810,42 @@ mod boundary_tests { } #[test] fn command_allowlist_redacts_unknown_names_and_startup_is_covered() { - for name in ["get_settings", "list_channels", "read_credential", "get_startup_snapshot", "check_accessibility_permission", "check_microphone_permission"] { + for name in ["get_settings", "list_channels", "read_credential", "get_startup_snapshot", "check_accessibility_permission", "check_microphone_permission", "get_settings_snapshot", "update_setting_fields", "set_settings", "set_qa_hotkey", "set_selection_polish_hotkey", "get_hotkey_capability", "set_shortcut_recording_active"] { assert!(Kind::command(name).is_some()); } for secret in ["", "personal-window", "secret-token", "get_settings extra-args", "plugin:untrusted|value"] { assert!(Kind::command(secret).is_none()); } - assert_eq!(Probe::P2.hypotheses(), ["H1", "H2", "H3", "H5"]); + assert_eq!(Probe::P2.hypotheses(), ["R1-H1", "R1-H2", "R1-H3", "R1-H4"]); + } + #[test] + fn webview_origin_is_a_closed_source_category_not_a_window_identity() { + assert_eq!(WebviewKind::from_label("main"), WebviewKind::Main); + assert_eq!(WebviewKind::from_label("private-window-name"), WebviewKind::Other); + let records = sink::capture(|| { + Operation::new(Probe::P1).record(Event::Entered, Outcome::Observed, + Facts { kind: Kind::SettingsSnapshot, + webview: WebviewKind::from_label("private-window-name"), ..Default::default() }); + }); + assert_eq!(records[0]["safe"]["webview"], "other"); + assert!(!records[0].to_string().contains("private-window-name")); + } + #[test] + fn settings_gate_is_the_real_parent_only_while_its_sync_guard_is_alive() { + let records = sink::capture(|| { + let mutex = parking_lot::Mutex::new(()); + let gate = lock_at(&mutex, Probe::P2, Kind::SettingsHostGate); + let dispatch = MainDispatch::new(Kind::HostDispatch); + dispatch.wrap(|| {})(); + dispatch.dispatched(true); + drop(gate); + assert!(current().is_none()); + assert!(mutex.try_lock().is_some()); + }); + let gate = records.iter().find(|r| r["probe_id"] == "P2" && r["event"] == "requested").unwrap(); + let dispatch = records.iter().find(|r| r["probe_id"] == "P4" && r["event"] == "requested").unwrap(); + assert_eq!(dispatch["parent_operation_id"], gate["operation_id"]); + assert!(records.iter().any(|r| r["probe_id"] == "P2" && r["event"] == "released")); } #[test] fn cancellation_is_scope_left_and_poll_context_never_leaks_across_await() { diff --git a/openless-all/app/src-tauri/src/issue1338_export.rs b/openless-all/app/src-tauri/src/issue1338_export.rs new file mode 100644 index 000000000..d0a2ac61c --- /dev/null +++ b/openless-all/app/src-tauri/src/issue1338_export.rs @@ -0,0 +1,213 @@ +//! Single-file diagnostic delivery. Never opens the live session in unit tests. +use super::{sink, LOG_NAME}; +use std::fs::File; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; + +type ExportResult = Result; +#[derive(Debug)] +pub(super) struct Snapshot { + pub(super) end: u64, + pub(super) checkpoint: Vec, + pub(super) incomplete: bool, +} + +fn session_path() -> ExportResult { + let base = std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()) + .ok_or("issue1338_session_unavailable: capture unknown")?; + Ok(PathBuf::from(base).join("OpenLess").join("diagnostics").join(LOG_NAME)) +} + +pub(super) fn export_log(source: &Path, target: &Path) -> ExportResult<()> { + // The file's existence is not evidence that this run ever had a working sink. + // Only an acknowledgment from the current writer can authorize a snapshot. + export_with(source, &session_path()?, target, sink::snapshot) +} + +fn canonical_target(target: &Path) -> ExportResult { + if target.exists() { + return target.canonicalize().map_err(|_| "issue1338_target_unavailable: capture unknown"); + } + let parent = target.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); + let name = target.file_name().ok_or("issue1338_target_unavailable: capture unknown")?; + Ok(parent.canonicalize().map_err(|_| "issue1338_target_unavailable: capture unknown")?.join(name)) +} + +fn same_path(left: &Path, right: &Path) -> ExportResult { + #[cfg(target_os = "windows")] + { + use std::os::windows::ffi::OsStrExt; + // Canonical paths still need Windows ordinal case-insensitive comparison. + #[link(name = "kernel32")] + unsafe extern "system" { + fn CompareStringOrdinal(a: *const u16, a_len: i32, b: *const u16, b_len: i32, ignore_case: i32) -> i32; + } + let a: Vec = left.as_os_str().encode_wide().collect(); + let b: Vec = right.as_os_str().encode_wide().collect(); + let a_len = i32::try_from(a.len()).map_err(|_| "issue1338_target_unavailable: capture unknown")?; + let b_len = i32::try_from(b.len()).map_err(|_| "issue1338_target_unavailable: capture unknown")?; + // SAFETY: both buffers remain alive and the lengths are checked above. + let comparison = unsafe { CompareStringOrdinal(a.as_ptr(), a_len, b.as_ptr(), b_len, 1) }; + if comparison == 0 { return Err("issue1338_target_unavailable: capture unknown"); } + Ok(comparison == 2) // CSTR_EQUAL + } + #[cfg(not(target_os = "windows"))] + { Ok(left == right) } +} + +fn export_with(source: &Path, session: &Path, target: &Path, + capture: impl FnOnce() -> ExportResult) -> ExportResult<()> { + let source_id = source.canonicalize().map_err(|_| "issue1338_application_log_unavailable: capture unknown")?; + let session_id = session.canonicalize().map_err(|_| "issue1338_session_unavailable: capture unknown")?; + let target_id = canonical_target(target)?; + // Resolve symlinks/junctions before comparison. No source is ever truncated, + // even if a different target name is a hardlink to it: persist replaces its entry. + if same_path(&target_id, &source_id)? || same_path(&target_id, &session_id)? { + return Err("issue1338_source_target_alias: capture unknown"); + } + let snapshot = capture()?; + if snapshot.incomplete { return Err("issue1338_capture_incomplete: capture unknown"); } + let mut diagnostics = File::open(session).map_err(|_| "issue1338_session_unavailable: capture unknown")?; + let checkpoint_len = snapshot.checkpoint.len() as u64; + let checkpoint_start = snapshot.end.checked_sub(checkpoint_len) + .ok_or("issue1338_snapshot_invalid: capture unknown")?; + // Validate the exact newly acknowledged checkpoint, not just a readable old + // file or a length. Later appends are deliberately excluded, including half-lines. + diagnostics.seek(SeekFrom::Start(checkpoint_start)) + .map_err(|_| "issue1338_snapshot_invalid: capture unknown")?; + let mut actual = vec![0; snapshot.checkpoint.len()]; + diagnostics.read_exact(&mut actual).map_err(|_| "issue1338_snapshot_invalid: capture unknown")?; + if actual != snapshot.checkpoint { return Err("issue1338_snapshot_invalid: capture unknown"); } + diagnostics.rewind().map_err(|_| "issue1338_snapshot_invalid: capture unknown")?; + let mut application = File::open(source).map_err(|_| "issue1338_application_log_unavailable: capture unknown")?; + // Bound the ordinary log too; it may continue growing during export. Its + // ordinary logger has no flush handshake, which is disclosed in the bundle. + let application_end = application.metadata().map_err(|_| "issue1338_application_log_unavailable: capture unknown")?.len(); + let parent = target.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); + let mut staging = tempfile::NamedTempFile::new_in(parent) + .map_err(|_| "issue1338_staging_failed: capture unknown")?; + let result = (|| -> std::io::Result<()> { + let copied = std::io::copy(&mut (&mut application).take(application_end), &mut staging)?; + if copied != application_end { return Err(std::io::Error::other("short application snapshot")); } + staging.write_all(b"\n--- OpenLess issue1338 diagnostic export v1 ---\napplication_log_tail_capture=unknown\ndiagnostic_bytes=end_at_current_writer_checkpoint\nhistorical_runs=not_current_coverage_unknown\n")?; + let copied = std::io::copy(&mut diagnostics.take(snapshot.end), &mut staging)?; + if copied != snapshot.end { return Err(std::io::Error::other("short diagnostic snapshot")); } + staging.write_all(b"--- end issue1338 diagnostic export ---\n")?; + staging.flush()?; + Ok(()) + })(); + result.map_err(|_| "issue1338_staging_failed: capture unknown")?; + // Never copy/truncate into an existing target. A failed persist leaves the + // old target and both inputs intact; temp cleanup is owned by NamedTempFile. + staging.persist(target).map_err(|_| "issue1338_publish_failed: capture unknown")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + fn snapshot() -> Snapshot { + let checkpoint = b"{\"run_id\":\"current\"}\n".to_vec(); + Snapshot { end: b"old\n".len() as u64 + checkpoint.len() as u64, checkpoint, incomplete: false } + } + fn fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let app = dir.path().join("openless.log"); + let diag = dir.path().join("session.ndjson"); + let target = dir.path().join("export.log"); + std::fs::write(&app, b"ordinary\n").unwrap(); + let mut bytes = b"old\n".to_vec(); + bytes.extend(snapshot().checkpoint); + std::fs::write(&diag, bytes).unwrap(); + std::fs::write(&target, b"preserve").unwrap(); + (dir, app, diag, target) + } + #[test] + fn single_file_includes_ordinary_history_current_checkpoint_but_no_concurrent_tail() { + let (_dir, app, diag, target) = fixture(); + let mut file = std::fs::OpenOptions::new().append(true).open(&diag).unwrap(); + file.write_all(b"{half-line").unwrap(); + export_with(&app, &diag, &target, || Ok(snapshot())).unwrap(); + let out = std::fs::read_to_string(target).unwrap(); + assert!(out.starts_with("ordinary\n")); + assert!(out.contains("old\n{\"run_id\":\"current\"}\n")); + assert!(out.contains("application_log_tail_capture=unknown")); + assert!(!out.contains("half-line")); + } + #[test] + fn stale_file_missing_file_loss_and_failed_capture_never_publish() { + let (_dir, app, diag, target) = fixture(); + assert!(export_with(&app, &diag, &target, || Err("no current writer")).is_err()); + assert!(export_with(&app, &diag, &target, || { let mut s = snapshot(); s.incomplete = true; Ok(s) }).is_err()); + std::fs::write(&diag, b"old snapshot only").unwrap(); + assert!(export_with(&app, &diag, &target, || Ok(snapshot())).is_err()); + std::fs::remove_file(&diag).unwrap(); + assert!(export_with(&app, &diag, &target, || panic!("missing file cannot capture")).is_err()); + assert_eq!(std::fs::read(target).unwrap(), b"preserve"); + assert_eq!(std::fs::read(app).unwrap(), b"ordinary\n"); + } + #[test] + fn source_targets_and_canonical_aliases_are_rejected_without_capture() { + let (dir, app, diag, _target) = fixture(); + let alias = dir.path().join(".").join("openless.log"); + for target in [&app, &diag, &alias] { + assert!(export_with(&app, &diag, target, || panic!("alias cannot capture")).is_err()); + } + assert_eq!(std::fs::read(app).unwrap(), b"ordinary\n"); + } + #[cfg(unix)] + #[test] + fn symlink_to_either_input_is_rejected_without_capture() { + let (dir, app, diag, _) = fixture(); + for (index, source) in [&app, &diag].into_iter().enumerate() { + let target = dir.path().join(format!("alias-{index}")); + std::os::unix::fs::symlink(source, &target).unwrap(); + assert!(export_with(&app, &diag, &target, || panic!("alias cannot capture")).is_err()); + } + } + #[cfg(target_os = "windows")] + #[test] + fn windows_canonical_path_comparison_is_case_insensitive() { + assert!(same_path(Path::new(r"C:\OpenLess\Logs\openless.log"), + Path::new(r"c:\openless\logs\OPENLESS.LOG")).unwrap()); + } + #[test] + fn hardlink_target_is_replaced_without_truncating_source() { + let (dir, app, diag, _) = fixture(); + let link = dir.path().join("linked.log"); + std::fs::hard_link(&app, &link).unwrap(); + export_with(&app, &diag, &link, || Ok(snapshot())).unwrap(); + assert_eq!(std::fs::read(app).unwrap(), b"ordinary\n"); + assert!(std::fs::read_to_string(link).unwrap().contains("current")); + } + #[test] + fn actual_production_encoder_checkpoint_and_unknowns_reach_the_single_delivered_log() { + let (_dir, app, diag, target) = fixture(); + let (bytes, snapshot) = sink::export_fixture(); + std::fs::write(&diag, bytes).unwrap(); + export_with(&app, &diag, &target, || Ok(snapshot)).unwrap(); + let out = std::fs::read_to_string(target).unwrap(); + let value: serde_json::Value = out.lines().filter_map(|line| serde_json::from_str::(line).ok()) + .find(|v| v["schema"] == "issue1338-export-checkpoint.v1").unwrap(); + assert_eq!(value["run_id"], "r1338"); + assert_eq!(value["capture_health"]["run_id"], "r1338"); + assert_eq!(value["capture_health"]["dropped"], 0); + assert_eq!(value["frontend_coverage"], serde_json::json!({ + "scope": "backend_only", "request_intent": "unknown", "request_send": "unknown", + "response_application": "unknown", "transport_loss": "unknown" + })); + assert_eq!(value["tail_capture"], "unknown"); + assert_eq!(value["historical_runs"], "included_not_current_coverage_unknown"); + assert_eq!(value["feature_enabled"], super::super::enabled()); + } + #[test] + fn failed_publish_preserves_sources_and_existing_target() { + let (dir, app, diag, _) = fixture(); + let target = dir.path().join("directory-target"); + std::fs::create_dir(&target).unwrap(); + assert!(export_with(&app, &diag, &target, || Ok(snapshot())).is_err()); + assert!(target.is_dir()); + assert_eq!(std::fs::read(app).unwrap(), b"ordinary\n"); + assert_eq!(std::fs::read(diag).unwrap().len() as u64, snapshot().end); + } +} diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index af88f283e..ef53a8122 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -1183,13 +1183,16 @@ fn observe_issue1338_invoke( handler: impl Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static, ) -> impl Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static { move |invoke| { - use issue1338_debug::{Boundary, Event, Kind, Outcome, Probe, SyncContext}; + use issue1338_debug::{Boundary, Event, Facts, Kind, Operation, Outcome, Probe, SyncContext, WebviewKind}; if !issue1338_debug::enabled() { return handler(invoke); } let Some(kind) = Kind::command(invoke.message.command()) else { return handler(invoke); }; - let boundary = Boundary::enter(Probe::P1, kind); + let webview = WebviewKind::from_label(invoke.message.webview_ref().label()); + let boundary = Boundary { operation: Operation::new(Probe::P1), kind }; + boundary.operation.record(Event::Entered, Outcome::Observed, Facts { kind, webview, ..Default::default() }); let _context = SyncContext::enter(boundary.operation); let handled = handler(invoke); - boundary.record(Event::DispatchReturned, if handled { Outcome::Ok } else { Outcome::Error }); + boundary.operation.record(Event::DispatchReturned, if handled { Outcome::Ok } else { Outcome::Error }, + Facts { kind, webview, ..Default::default() }); handled } } From ed5cfc031487272f1a77c1fdd2c6605aaf3afc2e Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Mon, 9 Nov 2026 01:15:00 +0800 Subject: [PATCH 3/4] fix(windows): open voice edit panel outside synchronous IPC --- docs/research/issue-1338-feedback-20261108.md | 34 ++++++++++++++ ...voice-edit-open-dispatch-contract.test.mjs | 45 +++++++++++++++++++ .../app/src-tauri/src/commands/voice_edit.rs | 18 ++++++++ .../app/src-tauri/src/issue1338_debug.rs | 19 ++++---- openless-all/app/src-tauri/src/lib.rs | 28 +++++++++--- 5 files changed, 130 insertions(+), 14 deletions(-) create mode 100644 docs/research/issue-1338-feedback-20261108.md create mode 100644 openless-all/app/scripts/voice-edit-open-dispatch-contract.test.mjs diff --git a/docs/research/issue-1338-feedback-20261108.md b/docs/research/issue-1338-feedback-20261108.md new file mode 100644 index 000000000..c54d0bd84 --- /dev/null +++ b/docs/research/issue-1338-feedback-20261108.md @@ -0,0 +1,34 @@ +# #1338 A2:点击语音编辑「打开面板」后的修复尝试 + +## 证据与范围 + +- 只读附件 `openless-2026-11-08T11-41-24-2.log`,108813614字节;SHA256 `cd1a2e1d15a4759fa7e1fef06672154bf21feef2c95881ee5cd1643d87c4de33`,与无`-2`附件哈希一致。禁止改写附件。 +- 包含8个运行的历史诊断,不能把相邻运行拼成因果链。A1构建`b743c50abc2d9e04f59eaeee2fa3e124ba470331`确实运行过。最终checkpoint属于重启后的`r18e60f1163561fa8`,不是故障候选运行的完整性担保;前端发送、应用与尾部捕获仍unknown。 +- 用户明确反馈:点击设置中语音编辑会话右侧「打开面板」必现。此前确认异常时语音输入和选区助手仍可用。此为用户报告的稳定触发/结果,不伪称开发机已复现。 +- `r18e60f01dcfc6bec`:L119910–119917有close_requested/destroyed,窗口类别未记录,不能断定关闭了哪个窗口。L119924–119955 P4操作237/240/243/246记录requested与dispatch_returned=ok,无entered/returned;对应P2父操作236/239/242/245仍acquired/released,后台继续运行。记录期内dropped计数0,不等于该历史运行尾部完整。语义SUSPICIOUS,不能宣称已证明整个主线程永久死锁。 +- 目前命令不在P1 allowlist,缺少本次点击与上述历史运行的直接operation关联;补对应入口/创建/显示边界,不伪造旧记录关联。 + +## 正常契约与语义检查 + +- N7:主窗口点击打开面板后,应完成面板创建/显示并保留主窗口IPC可用;voice-edit session仍由面板后续start_voice_edit_session启动,非main调用保持拒绝。来源:用户期望、SelectionWorkspaceSection.tsx、commands/voice_edit.rs注释及window标签校验。用户报告VIOLATION;旧日志具体点击阶段INSUFFICIENT_EVIDENCE。 +- N8:接受的主线程任务不应因窗口创建重入而使后续工作无进展;派发成功只证明入队,不证明执行。来源:Tauri宿主派发契约。上述P4为SUSPICIOUS,重启/关闭及尾部截断替代解释未排除。 +- N2原设置/权限请求应继续完成;用户报告VIOLATION,现场未直接记录每个页面Promise。 +- E1:A1内置导出携带专用NDJSON和当前checkpoint已观测成立(L120835);NO_VIOLATION_OBSERVED,仅限这次导出,不代表原故障修复。 + +## 修复前假设 + +| ID | Hypothesis | Falsifiable prediction | If true, what change would make the bug disappear? | Probe IDs | +|---|---|---|---|---| +| R2-H1 | Windows同步IPC命令中懒创建WebView发生重入/同步等待(N7/N8) | 打开命令进入后创建阶段无返回;异步分派同一命令后创建、显示返回且主窗口请求恢复 | 仅把voice_edit_window_open改async,不变校验、会话、窗口参数 | P1/P2/P4 | +| R2-H2 | 窗口已创建,Windows focus/show阶段阻塞(N7/N8) | 创建returned而focus或show无returned;H1变更后同样停在该阶段 | 仅调整证实的显示阶段执行边界 | P2/P4 | +| R2-H3 | 窗口关闭/销毁后的宿主事件队列生命周期异常(N8) | 对应窗口生命周期与后续任务不执行相关,创建显示此前完成;需固定窗口类别观察 | 仅修证实的宿主句柄生命周期,不重建整个后端 | P4/P6 | +| R2-H4 | 后端完成打开而主窗口前端readiness/请求应用挂起(N2/N7) | 创建显示完整返回,原主窗口页面Promise仍挂起;需前端补证 | 仅修已定位页面/桥生命周期,不盲目重试 | P1/P2/P4;frontend仍unknown | + +## 修复资格与单变量实验 + +- 锁定依赖为src-tauri/Cargo.lock中的Tauri2.12.4。官方对应tag源码:`https://raw.githubusercontent.com/tauri-apps/tauri/tauri-v2.12.4/crates/tauri/src/webview/webview_window.rs` L58–59、L115–116明确警告Windows同步命令/事件处理器创建WebviewWindow会deadlock,并推荐async command或独立线程。 +- 当前实际路径:SelectionWorkspaceSection点击 -> openVoiceEditWindow -> voice_edit_window_open(同步)-> TauriCoordinatorHost::show_voice_edit -> show_voice_edit_window -> ensure_voice_edit_window -> WebviewWindowBuilder::build。主窗口标签限制仍保留。 +- R2-H1=INCONCLUSIVE causal confidence / ACTIONABLE reversible experiment:用户稳定触发 + 锁定API已知重入约束 + 现场主线程派发无进展而后台锁仍运行,支持针对这一条边界做单变量实验;不是宣称其他原因已排除。R2-H2/H3/H4仍INCONCLUSIVE。 +- attempt_id=A2-voice-edit-open-async;change_id=C2-async-command-dispatch。仅命令sync->async改变业务执行边界;增加既有P1/P2/P4内的闭合阶段,不新增P7,不改超时/重试/锁顺序/窗口参数/权限/会话提交。 +- 测试先编写,运行仅Actions;未执行本地测试、编译、安装或应用启动,不声称观察到red/green。 +- 下一门禁:完成候选→review-diff-bugs完整复审至no bug found→提交/push fork→准确SHA Actions CI/桌面/移动端→交付新包固定触发复现。继续同一个session NDJSON,保留历史;用户确认修复前不清理。 diff --git a/openless-all/app/scripts/voice-edit-open-dispatch-contract.test.mjs b/openless-all/app/scripts/voice-edit-open-dispatch-contract.test.mjs new file mode 100644 index 000000000..b59055df1 --- /dev/null +++ b/openless-all/app/scripts/voice-edit-open-dispatch-contract.test.mjs @@ -0,0 +1,45 @@ +// Regression contract for the Windows WebView-creation dispatch boundary. +// This source contract cannot reproduce a native event-loop deadlock: Actions +// must also compile the real host, and the affected device must verify N7/N8. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const read = (relative) => readFileSync(new URL(relative, import.meta.url), 'utf8').replace(/\r\n/g, '\n'); +const command = read('../src-tauri/src/commands/voice_edit.rs'); +const diagnostics = read('../src-tauri/src/issue1338_debug.rs'); +const host = read('../src-tauri/src/lib.rs'); + +function openCommand(source) { + const start = source.indexOf('pub async fn voice_edit_window_open('); + assert.notEqual(start, -1, 'window creation must not run inside a synchronous IPC command'); + const end = source.indexOf('\n#[tauri::command]', start); + assert.notEqual(end, -1, 'the next command must bound extraction'); + return source.slice(start, end); +} + +test('voice edit creation leaves the synchronous IPC handler and retains main-only authorization', () => { + const body = openCommand(command); + assert.match(body, /window\.label\(\) != "main"/); + assert.match(body, /return Err\("Voice Edit can only be opened from the main window"\.to_string\(\)\)/); + assert.ok(body.indexOf('return Err(') < body.indexOf('show_voice_edit()')); + assert.match(body, /observe_future\([\s\S]*Kind::VoiceEditOpen[\s\S]*async move/); + assert.match(body, /show_voice_edit\(\);[\s\S]*Ok\(\(\)\)/); + assert.doesNotMatch(body, /start_voice_edit_session\(/, 'opening must not start or commit a session'); +}); + +test('diagnostic source preserves the specific open and native-stage boundaries', () => { + assert.match(diagnostics, /"voice_edit_window_open"\s*=>\s*Some\(Self::VoiceEditOpen\)/); + for (const kind of ['VoiceEditLookup', 'VoiceEditCreate', 'VoiceEditFocusable', 'VoiceEditShow']) { + assert.ok(host.includes(`Kind::${kind}`), `${kind} must observe the real native stage`); + } + assert.match(diagnostics, /request_intent": "unknown"/); + assert.match(diagnostics, /response_application": "unknown"/); +}); + +for (const newline of ['\n', '\r\n']) { + test(`command boundary is stable under ${JSON.stringify(newline)} checkout`, () => { + const normalized = command.replace(/\n/g, newline).replace(/\r\n/g, '\n'); + assert.ok(openCommand(normalized).includes('show_voice_edit()')); + }); +} diff --git a/openless-all/app/src-tauri/src/commands/voice_edit.rs b/openless-all/app/src-tauri/src/commands/voice_edit.rs index 2a8f0c4e2..e1fc5c0bc 100644 --- a/openless-all/app/src-tauri/src/commands/voice_edit.rs +++ b/openless-all/app/src-tauri/src/commands/voice_edit.rs @@ -3,6 +3,24 @@ use super::CoordinatorState; /// Opens the desktop Voice Edit panel or asks Android's main WebView to show /// its embedded equivalent. The session starts only after the panel invokes /// `start_voice_edit_session`. +// Tauri 2.12.4 warns against Windows WebView creation in synchronous IPC. +// [DEBUG-ISSUE-1338] A2 changes only this command's dispatch boundary. +#[cfg(target_os = "windows")] +#[tauri::command] +pub async fn voice_edit_window_open( + window: tauri::Window, + coord: CoordinatorState<'_>, +) -> Result<(), String> { + crate::issue1338_debug::observe_future(crate::issue1338_debug::Kind::VoiceEditOpen, async move { + if window.label() != "main" { + return Err("Voice Edit can only be opened from the main window".to_string()); + } + coord.tauri_host().show_voice_edit(); + Ok(()) + }).await +} + +#[cfg(not(target_os = "windows"))] #[tauri::command] pub fn voice_edit_window_open( window: tauri::Window, diff --git a/openless-all/app/src-tauri/src/issue1338_debug.rs b/openless-all/app/src-tauri/src/issue1338_debug.rs index b9c097aed..11a9fe6ab 100644 --- a/openless-all/app/src-tauri/src/issue1338_debug.rs +++ b/openless-all/app/src-tauri/src/issue1338_debug.rs @@ -1,4 +1,4 @@ -//! [DEBUG-ISSUE-1338] A1 diagnostic delivery repair, not a confirmed stall fix. +//! [DEBUG-ISSUE-1338] A2 Windows panel-open experiment; effectiveness unconfirmed. //! Fixed closed facts only. Producers never serialize, write, initialize a sink, //! acquire application locks, or wait for queue capacity. try_send is not lock-free. //! Missing records and the final flush tail remain unknown, including forced exit. @@ -9,8 +9,8 @@ //! P5 covers exported native checks, not every internal platform helper. #![allow(dead_code)] use serde::Serialize; -pub(crate) const ATTEMPT: &str = "A1-diagnostic-delivery"; -pub(crate) const CHANGE: &str = "C1-single-file-diagnostic-export"; +pub(crate) const ATTEMPT: &str = "A2-voice-edit-open-async"; +pub(crate) const CHANGE: &str = "C2-async-command-dispatch"; pub(crate) const SESSION: &str = "issue-1338-20261106"; pub(crate) const LOG_NAME: &str = "openless-issue-1338-20261106-session.ndjson"; pub(crate) const fn enabled() -> bool { @@ -21,9 +21,9 @@ pub(crate) enum Probe { P1, P2, P3, P4, P5, P6 } impl Probe { fn hypotheses(self) -> &'static [&'static str] { match self { - Self::P1 | Self::P4 | Self::P6 => &["R1-H1", "R1-H2", "R1-H3", "R1-H4"], - Self::P2 => &["R1-H1", "R1-H2", "R1-H3", "R1-H4"], - Self::P3 => &["R1-H2"], Self::P5 => &["R1-H1"], + Self::P1 | Self::P2 | Self::P4 => &["R2-H1", "R2-H2", "R2-H3", "R2-H4"], + Self::P6 => &["R2-H3"], + Self::P3 | Self::P5 => &["R2-H4"], } } } @@ -51,11 +51,13 @@ pub(crate) enum Kind { SettingsHostGate, SnapshotRevision, SnapshotPreferences, SnapshotVerify, SettingsPersist, SettingsRefresh, SelectionHotkeyRegistration, QaHotkeyRegistration, OtherHotkeyRegistration, + VoiceEditOpen, VoiceEditLookup, VoiceEditCreate, VoiceEditFocusable, VoiceEditShow, } impl Kind { // Never pass through arbitrary command names, arguments, labels or payloads. pub(crate) fn command(command: &str) -> Option { match command { + "voice_edit_window_open" => Some(Self::VoiceEditOpen), "get_settings" => Some(Self::GetSettings), "get_settings_snapshot" => Some(Self::SettingsSnapshot), "update_setting_fields" => Some(Self::UpdateSettingFields), @@ -630,7 +632,7 @@ mod sink { assert_eq!(value["change_id"], CHANGE); assert_eq!(value["schema"], "diagnosing-bugs.v1"); assert_eq!(value["probe_id"], "P5"); - assert_eq!(value["hypothesis_ids"], serde_json::json!(["R1-H1"])); + assert_eq!(value["hypothesis_ids"], serde_json::json!(["R2-H4"])); assert_eq!(value["event"], "scope_left"); assert_eq!(value["outcome"], "observed"); assert_eq!(value["parent_operation_id"], 1); @@ -816,7 +818,8 @@ mod boundary_tests { for secret in ["", "personal-window", "secret-token", "get_settings extra-args", "plugin:untrusted|value"] { assert!(Kind::command(secret).is_none()); } - assert_eq!(Probe::P2.hypotheses(), ["R1-H1", "R1-H2", "R1-H3", "R1-H4"]); + assert_eq!(Probe::P2.hypotheses(), ["R2-H1", "R2-H2", "R2-H3", "R2-H4"]); + assert!(matches!(Kind::command("voice_edit_window_open"), Some(Kind::VoiceEditOpen))); } #[test] fn webview_origin_is_a_closed_source_category_not_a_window_identity() { diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index ef53a8122..26c6de595 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -3373,10 +3373,17 @@ const VOICE_EDIT_WINDOW_HEIGHT: f64 = 620.0; fn ensure_voice_edit_window( app: &AppHandle, ) -> Option> { - if let Some(window) = app.get_webview_window("voice-edit") { + use issue1338_debug::{Boundary, Event, Facts, Kind, Outcome, Probe}; + let lookup = Boundary::enter(Probe::P4, Kind::VoiceEditLookup); + let existing = app.get_webview_window("voice-edit"); + lookup.operation.record(Event::Returned, Outcome::Returned, + Facts { kind: Kind::VoiceEditLookup, flag: existing.is_some(), ..Default::default() }); + drop(lookup); + if let Some(window) = existing { return Some(window); } - match WebviewWindowBuilder::new( + let create = Boundary::enter(Probe::P4, Kind::VoiceEditCreate); + let created = WebviewWindowBuilder::new( app, "voice-edit", WebviewUrl::App("index.html?window=voice-edit".into()), @@ -3393,8 +3400,10 @@ fn ensure_voice_edit_window( .focused(false) .visible(false) .accept_first_mouse(true) - .build() - { + .build(); + create.record(Event::Returned, if created.is_ok() { Outcome::Ok } else { Outcome::Error }); + drop(create); + match created { Ok(window) => { #[cfg(target_os = "macos")] { @@ -3456,8 +3465,15 @@ pub(crate) fn show_voice_edit_window(app: &AppHandle) { #[cfg(target_os = "windows")] { // Preserve the external editor until capture; QA's helper deliberately focuses its WebView. - let _ = window.set_focusable(false); - let _ = window.show(); + use issue1338_debug::{Boundary, Event, Kind, Outcome, Probe}; + let focus = Boundary::enter(Probe::P4, Kind::VoiceEditFocusable); + let result = window.set_focusable(false); + focus.record(Event::Returned, if result.is_ok() { Outcome::Ok } else { Outcome::Error }); + drop(focus); + let show = Boundary::enter(Probe::P4, Kind::VoiceEditShow); + let result = window.show(); + show.record(Event::Returned, if result.is_ok() { Outcome::Ok } else { Outcome::Error }); + drop(show); } #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] let _ = window.show(); From 682368afd6f2ae877da05ad1a93b43fce513220d Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Mon, 9 Nov 2026 21:17:52 +0800 Subject: [PATCH 4/4] chore(windows): remove issue 1338 diagnostic instrumentation --- .github/workflows/ci.yml | 8 - .github/workflows/release-tauri.yml | 29 +- docs/research/issue-1338-backend-hang.md | 77 -- docs/research/issue-1338-feedback-20261107.md | 69 -- docs/research/issue-1338-feedback-20261108.md | 34 - .../scripts/backend-hang-workflow.test.mjs | 53 - .../scripts/test-backend-hang-diagnostics.py | 59 -- .../app/scripts/test-recorder-module.py | 2 - ...voice-edit-open-dispatch-contract.test.mjs | 31 +- openless-all/app/src-tauri/Cargo.toml | 1 - .../backend-tests/tests/audio_processing.rs | 2 - .../app/src-tauri/src/commands/channels.rs | 10 +- .../app/src-tauri/src/commands/credentials.rs | 25 +- .../app/src-tauri/src/commands/dictation.rs | 4 +- .../app/src-tauri/src/commands/hotkeys.rs | 7 - .../app/src-tauri/src/commands/misc.rs | 29 +- .../src/commands/permissions_cmds.rs | 32 +- openless-all/app/src-tauri/src/commands/qa.rs | 7 - .../app/src-tauri/src/commands/settings.rs | 34 +- .../app/src-tauri/src/commands/voice_edit.rs | 16 +- .../app/src-tauri/src/issue1266_debug.rs | 19 +- .../app/src-tauri/src/issue1338_debug.rs | 956 ------------------ .../app/src-tauri/src/issue1338_export.rs | 213 ---- openless-all/app/src-tauri/src/lib.rs | 85 +- openless-all/app/src-tauri/src/permissions.rs | 32 +- .../src-tauri/src/persistence/credentials.rs | 114 +-- .../credentials/android_transfer.rs | 4 +- .../src-tauri/src/tauri_coordinator_host.rs | 82 +- 28 files changed, 163 insertions(+), 1871 deletions(-) delete mode 100644 docs/research/issue-1338-backend-hang.md delete mode 100644 docs/research/issue-1338-feedback-20261107.md delete mode 100644 docs/research/issue-1338-feedback-20261108.md delete mode 100644 openless-all/app/scripts/backend-hang-workflow.test.mjs delete mode 100644 openless-all/app/scripts/test-backend-hang-diagnostics.py delete mode 100644 openless-all/app/src-tauri/src/issue1338_debug.rs delete mode 100644 openless-all/app/src-tauri/src/issue1338_export.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0acdd3c5..1dfca882b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -815,14 +815,6 @@ jobs: timeout-minutes: 10 run: python scripts/test-windows-hang-diagnostics.py - - name: Compile opt-in backend observation host - timeout-minutes: 25 - run: cargo check --locked --manifest-path src-tauri/Cargo.toml --features issue-1338-diagnostics - - - name: Run isolated backend observation tests (off/on) - timeout-minutes: 10 - run: python scripts/test-backend-hang-diagnostics.py - - name: Run owner-thread target ownership tests timeout-minutes: 10 run: python scripts/test-windows-runtime-target.py diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 46aaa643c..4a1312535 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -40,11 +40,6 @@ on: type: boolean required: false default: false - windows_backend_diagnostics: - description: Opt-in issue 1338 backend observation build (branch only, not a fix) - type: boolean - required: false - default: false release_tag: description: Existing draft Stable tag to repair (admin only, Windows only) type: string @@ -85,25 +80,10 @@ jobs: # On workflow_dispatch / non-tag triggers github.ref_name is not a tag string, endsWith # returns false, and it falls back to stable — dispatch behavior unchanged. OPENLESS_HANG_DIAGNOSTICS: ${{ github.event_name == 'workflow_dispatch' && inputs.windows_hang_diagnostics && matrix.platform == 'windows-latest' && startsWith(github.ref, 'refs/heads/') && inputs.release_tag == '' && 'true' || 'false' }} - OPENLESS_BACKEND_DIAGNOSTICS: ${{ github.event_name == 'workflow_dispatch' && inputs.windows_backend_diagnostics && matrix.platform == 'windows-latest' && startsWith(github.ref, 'refs/heads/') && inputs.release_tag == '' && 'true' || 'false' }} OPENLESS_RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }} OPENLESS_RELEASE_IS_TAG: ${{ (startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-tauri')) || inputs.release_tag != '' }} OPENLESS_RELEASE_CHANNEL: ${{ (endsWith(inputs.release_tag || github.ref_name, '-beta-tauri') || contains(inputs.release_tag || github.ref_name, '-Beta.')) && 'beta' || 'stable' }} steps: - - name: Validate independent diagnostic sessions - if: github.event_name == 'workflow_dispatch' && inputs.windows_backend_diagnostics - shell: bash - env: - LEGACY_DIAGNOSTICS: ${{ inputs.windows_hang_diagnostics }} - INPUT_RELEASE_TAG: ${{ inputs.release_tag }} - INPUT_PLATFORM: ${{ inputs.platform }} - run: | - set -euo pipefail - [ "$LEGACY_DIAGNOSTICS" != true ] || { echo "::error::Choose one diagnostic session, not both"; exit 1; } - [ -z "$INPUT_RELEASE_TAG" ] || { echo "::error::Backend observations are branch-only, never release repairs"; exit 1; } - [[ "$GITHUB_REF" == refs/heads/* ]] || { echo "::error::Backend observations require a branch ref"; exit 1; } - [ "$INPUT_PLATFORM" != macos ] || { echo "::error::Backend observations require the Windows platform"; exit 1; } - - name: Validate targeted draft release repair if: inputs.release_tag != '' shell: bash @@ -372,10 +352,6 @@ jobs: if [ "$OPENLESS_HANG_DIAGNOSTICS" = true ]; then diagnostic_args=(--features issue-1266-diagnostics) fi - if [ "$OPENLESS_BACKEND_DIAGNOSTICS" = true ]; then - diagnostic_args=(--features issue-1338-diagnostics) - fi - echo "backend_observation=$OPENLESS_BACKEND_DIAGNOSTICS legacy_observation=$OPENLESS_HANG_DIAGNOSTICS source_sha=$OPENLESS_BUILD_ID" # WiX/MSI only accepts a numeric prerelease identifier. Manual # validation runs use the repository's Beta version even though the # ref is a branch (and therefore OPENLESS_RELEASE_CHANNEL=stable), @@ -607,8 +583,7 @@ jobs: platform = 'windows' protocol_revision = '0x4F4C0009' issue_1266_diagnostics = ($env:OPENLESS_HANG_DIAGNOSTICS -eq 'true') - issue_1338_diagnostics = ($env:OPENLESS_BACKEND_DIAGNOSTICS -eq 'true') - diagnostic_session = $(if ($env:OPENLESS_BACKEND_DIAGNOSTICS -eq 'true') { 'issue-1338-20261106' } elseif ($env:OPENLESS_HANG_DIAGNOSTICS -eq 'true') { 'issue-1266-2e2c7a47' } else { $null }) + diagnostic_session = $(if ($env:OPENLESS_HANG_DIAGNOSTICS -eq 'true') { 'issue-1266-2e2c7a47' } else { $null }) } | ConvertTo-Json -Compress | Set-Content -Encoding utf8NoBOM (Join-Path $manifestDir 'openless-provenance.json') # ── Collect artifacts ── @@ -717,7 +692,7 @@ jobs: if: matrix.platform == 'windows-latest' uses: actions/upload-artifact@v4 with: - name: ${{ env.OPENLESS_BACKEND_DIAGNOSTICS == 'true' && 'openless-windows-x64-issue-1338-diagnostics' || env.OPENLESS_HANG_DIAGNOSTICS == 'true' && 'openless-windows-x64-issue-1266-diagnostics' || 'openless-windows-x64' }} + name: ${{ env.OPENLESS_HANG_DIAGNOSTICS == 'true' && 'openless-windows-x64-issue-1266-diagnostics' || 'openless-windows-x64' }} path: | openless-all/app/src-tauri/target/release/bundle/nsis/*.exe openless-all/app/src-tauri/target/release/bundle/msi/*.msi diff --git a/docs/research/issue-1338-backend-hang.md b/docs/research/issue-1338-backend-hang.md deleted file mode 100644 index 83dcf0ac0..000000000 --- a/docs/research/issue-1338-backend-hang.md +++ /dev/null @@ -1,77 +0,0 @@ -# #1338 前端正常、后端相关操作卡住:观察轮次 A0 - -## 范围与证据资格 - -本轮基线为上游 `Open-Less/openless beta` 的 `c002b56c38d81bcd5044ddb22597a4e78ff2a88d`,不是用户现场安装提交。用户描述:快捷键不响应/不稳定,随后发现大模型配置无法加载、权限检测失败、托盘退出无效;前端界面仍正常。发现顺序不等于稳定触发步骤。现场 Windows 精确版本、OpenLess 安装版本、故障时刻和前后端进程存活情况未知。 - -只读输入是 `openless-2026-11-06T14-37-57.log`,累计多日、11995 行。原文件不更改、不上传全文;不把文件名当作故障时刻或安装版本。本轮不能以静态代码推断确认根因,因此只允许观察候选,不先改锁、调度、权限、超时、重试、退出或业务行为。 - -开发在 `D:/Dev/worktrees/openless/issue-1338-backend-hang`,分支 `fix/issue-1338-backend-hang`;不动原克隆。所有测试、安装依赖及构建仅在 Actions;提交前独立 `review-diff-bugs` 审查到 `no bug found`,仅推 fork,不提 PR、不发 release。 - -唯一会话日志预留为 `%LOCALAPPDATA%/OpenLess/diagnostics/openless-issue-1338-20261106-session.ndjson`,只追加、不覆盖、不轮转;调查过程不创建现场日志。A0 不是行为修复,不满足原症状验收。此后有效的单变量修复必须保留同一探针与会话文件,使用新的 attempt/change 身份。 - -## 正常行为模型与已有证据语义检查 - -| Contract | 期望及来源 | 可检查信号/边界 | 已有证据分类 | -|---|---|---|---| -| N1 | 用户触发快捷键应得到相应功能的可见结果(用户需求);合法禁用、取消和冲突单独区分 | 物理按键→监听→业务→可见结果;具体性能预算未定义,不编造秒数 | 用户反馈 VIOLATION;日志对本次请求关联 INSUFFICIENT_EVIDENCE | -| N2 | 打开模型配置应得到配置或明确失败,而非无结果(用户需求) | 请求、后端服务、响应、前端应用;不记录配置值 | 用户反馈 VIOLATION;日志无关联边界,INSUFFICIENT_EVIDENCE | -| N3 | 权限检测应返回真实状态或可辨认失败,不能把卡住当权限被拒绝(用户需求) | 后端检查进入/原生调用/返回;平台不适用是合法结果 | 用户反馈检测失败;具体错误类型和阶段 INSUFFICIENT_EVIDENCE | -| N4 | 用户主动托盘退出应能结束应用(用户需求) | 菜单回调、退出请求、清理、进程终止(外部观察) | 用户反馈 VIOLATION;日志无关联退出请求,INSUFFICIENT_EVIDENCE | -| N5 | 前端纯界面正常不等于后端健康;局部调用成功不等于后续全局健康 | 分别观察纯 UI、IPC、执行和可见结果,不用 heartbeat 替代业务进度 | 用户澄清前端正常;11983/11987 局部完成不能否定之后卡住 | - -状态版本、重复副作用和计数守恒不是当前已知症状,本轮不虚构该类契约。缺少经验证的日志覆盖、flush、请求对应关系和观测截止时刻,不能用缺少事件证明代码未执行。 - -有限日志线索(UTC): - -- L11570:08:57:23Z,选区润色快捷键注册失败且重试计数 5340。只说明该时段的注册异常;不是本次跨功能卡住的已证实起因。 -- L11983/L11987:14:29:42Z–43Z,有同一次语音流完成及插入 `PasteSent` 的后端局部记录;不证明用户界面内容正确,也不证明其后健康。 -- L11989:14:37:22Z 出现启动记录;不证明此前如何终止。 -- 其他时段 `sign_in_required` 不能视作本次系统权限检测失败。 - -即使删去所有 ERROR/WARN,用户报告的 N1/N2/N4 仍是行为异常;现有 INFO 不足以确认其因果边界。当前没有能稳定复现原故障的红色反馈环。 - -## 初始可证伪假设(静态线索不是结论) - -以下是排名初始集,接缝需以实际实现和最终探针表校准。每个假设当前均为 `INCONCLUSIVE`,尚无 `ACTIONABLE` 行为修复资格。 - -| ID | Hypothesis | Falsifiable prediction | If true, what change would make the bug disappear? | Probe IDs | -|---|---|---|---|---| -| H1 | 同步 `get_settings` 的 preferences 读取或 warm-capture configure/stop 阶段阻塞了原生调用派发,使后续读取及托盘事件不能继续(N2/N4/N5) | 同一生成线程的同步配置命令在 preferences 或 warm configure 阶段进入后未返回,其他原生主线程任务未执行;需结合完整覆盖及现场线程栈排除捕获损失 | 只将证实阻塞的该同步操作移出主线程且保持结果/顺序语义,原跨功能停滞应消失 | P1/P2/P4/P6 | -| H2 | 权限原生麦克风检查在异步 worker 内同步阻塞,导致其他异步服务没有执行机会(N2/N3/N5) | 原生 mic 阶段进入后停住,异步命令已派发但执行/完成停滞,而原生事件循环仍可执行;必须结合线程栈/执行资源观察区分孤立慢调用 | 只把证实阻塞的 mic 检查迁至合适的 blocking 执行边界,其他请求应恢复且权限语义不变 | P1/P2/P5/P6 | -| H3 | 凭据共享锁持有路径停滞,配置读取等待该锁,并可能占住调用执行资源(N2/N5) | 生成锁操作显示已持有而其他操作等待;命令与该锁必须有真实父子/同线程调用关联;原生权限可独立返回时可否定全局卡住归因 | 只缩短已证实持锁路径或修正已证实锁环,保留凭据授权/缓存/写入原子性,配置读取与其他请求恢复 | P2/P3 | -| H4 | 后端任务在原生主线程派发边界等待,托盘事件也不能进入;不是前端渲染冻结(N1/N4/N5) | 真实主线程排队已接受但执行边界停滞,现场栈显示该队列被占用;原生事件回调/退出进入边界缺失只有覆盖完整时才可用 | 只移除已证实的主线程阻塞或同步重入等待,保持窗口资源所有权与业务取消语义 | P1/P4/P6 | -| H5 | 同一前端页面缓存的 startup readiness 未完成,普通命令在前端共同前置阶段等待,尚未真正发送(N2/N3/N5) | 原生 `get_startup_snapshot` 已开始而未完成,现场前端 ready Promise 仍 pending;需要前端现场检查,不把后端缺少命令入口等同于未发送;若同一页面 ready 已 fulfilled,则不能用后来挂起解释它再次 pending | 只修正证据定位的 startup 阶段阻塞,保持恢复/凭据与生命周期安全边界,普通调用应开始发送并返回 | P1/P2(前端状态覆盖未知,须另取现场) | - -源代码核对:`src/lib/ipc/shared.ts:48–63,77–95` 将普通调用放在缓存的 readiness 后;`src-tauri/src/commands/dictation.rs` 的 startup 命令调用 `crates/openless-core/src/api.rs:4967–4997` 的 `start()`,包含 lifecycle gate、偏好、凭据预热与恢复,而非纯存活检查。`commands/settings.rs:24–28,242–256` 的同步设置读取还会调用 warm configure;`recorder.rs:1353–1360,1388,1405` 涉及 lifecycle gate 与 stop。`core_adapters.rs:1642–1663` 的权限 future 内含同步原生调用;`permissions.rs:441–442` 的 Windows accessibility 是 NotApplicable,而 `:451–471` 的麦克风会探测设备/建流。权限前端 `Promise.all` 可因一项悬挂而整组不更新,因此不能仅凭权限 UI 推断所有 worker 饥饿。以上均为基线源码契约,不是运行时因果确认。 - -## 探针约束 - -最多六个真实边界:P1 原生 invoke 派发;P2 命令执行/服务等待;P3 凭据锁等待/持有;P4 到原生主线程队列的真实排队/执行;P5 权限原生检查;P6 托盘退出/应用生命周期。最终实现必须标明支持的命令、线程/父子标签和未覆盖部分。 - -仅后端观察不覆盖前端请求意图、前端发送失败、页面生命周期或响应应用结果。不得把后端没收到当作前端没发送,也不得把 dispatch 返回当作异步业务完成。日志导出不依赖挂住的 IPC:使用文件系统取得唯一会话文件;若当前 writer 健康/尾部 flush 不可观察,明确为覆盖未知。没有前端诊断 transport,不能虚报前端丢失计数为零。 - -探针必须默认关闭、仅显式分支诊断 Windows 包启用;所有事实只用闭合枚举、布尔值、计数、生成的运行内操作/线程标签和单调时长。不得含命令参数、模型/渠道/设备/窗口标识、路径、内容、凭据或原始错误。生产热路径不做序列化、磁盘写入、同步等待或新增延迟/重试;队列必须有界且损失可辨认,不宣称 lock-free。取消/unwind 仅算 scope-left,不能当成功。 - -## A0 实际实现与覆盖边界 - -- Cargo feature `issue-1338-diagnostics` 默认关闭,Windows 显式启用后才初始化真实 writer;不是旧 `issue-1266-diagnostics` 会话。 -- P1 仅 allowlist:`get_startup_snapshot`、`get_settings`、`list_channels`、`read_credential`、两项权限检测。handler 的 bool 只表示派发/处理返回,不是 async 完成;其他 IPC 无覆盖。 -- P2 观察以上命令的服务执行,设置读取区分 PrefsRead/WarmConfigure;凭据 blocking worker 使用显式生成的 operation。startup 仅命令整体,不覆盖 Core 内部每个恢复子阶段;`get_settings_snapshot` 当前不在覆盖内。 -- P3 包装实际凭据总锁和 cache 锁,释放记录在真实 unlock 后;不改变锁顺序/生命周期或凭据内容。其他锁没有被凭据锁探针覆盖。 -- P4 host/capsule 主线程包装区分 requested、dispatch_returned、entered、returned/scope_left;requested 可能早于 handle 获取,不能仅凭 requested 认定成功排队。 -- P5 观察导出的原生权限检查进入/返回和闭合 PermissionStatus;不观察所有平台内部 helper,也不细分底层设备/建流/play/drop 的每一步。要进一步区分底层卡点须结合现场栈,而非凭单一进入记录猜测。 -- P6 观察 tray quit、request_quit 和既有 Ready/Close/Destroyed/ExitRequested/Exit 回调。保留上游已有 3 秒强退兜底;没有增加产品延迟。Exit callback 本身不证明进程终止。 -- async 观察只在每次 poll 的同步栈安装/恢复 TLS,不跨 await 维持线程上下文。缺少真实父关联显示 unknown,不以邻近记录补关联。 -- writer 容量 512、`try_send`,各记录携带健康快照;`tail_capture=unknown` 始终保留,flush 为 OS 写入而非 fsync。现场可能有最终未落盘记录或初始化/磁盘失败;过去的零丢失不是完整覆盖证明。 -- 可选 `issue1338_health` 仅诊断 Windows 包注册,独立读取原子状态;仍依赖 IPC,卡住时不保证可用。直接从文件系统取得会话文件,不以普通应用日志导出代替。 - -### 分支构建与交付约束 - -`Release Tauri (cross-platform)` 手动指定当前分支、`platform=all`、`windows_backend_diagnostics=true`、`windows_hang_diagnostics=false`,`release_tag` 留空。两种诊断会话互斥。Windows artifact 名为 `openless-windows-x64-issue-1338-diagnostics`,包含 source provenance 与匹配 EXE/PDB 验证清单;macOS 是普通构建。CI 显式编译 feature-on 宿主并运行导入真实模块的 off/on 隔离测试;这些测试不打开现场会话文件。测试通过只证明诊断层契约,不证明原设备问题修复。 - -同时为准确 head 运行分支 CI(含 Linux 包门禁)及 Android 全 ABI 分支构建,不创建 PR/tag/release。验证 run head、实际 job、非空未过期 artifact 后才交付;artifact archive digest 不等于内部安装文件的 checksum。 - -## 验收与下一步 - -A0 先接受诊断本身的安全性、边界覆盖、默认关闭和 Actions 产物来源;不宣告修复。有效行为修复须取得对应现场/探针证据,选一个可逆因果变量,验证同一 N1–N5 用户可见结果及退出结果,再经用户确认后清理探针。两次不能重现并不自动证明有效。 diff --git a/docs/research/issue-1338-feedback-20261107.md b/docs/research/issue-1338-feedback-20261107.md deleted file mode 100644 index 62878886d..000000000 --- a/docs/research/issue-1338-feedback-20261107.md +++ /dev/null @@ -1,69 +0,0 @@ -# #1338 第二份现场:2026-11-07 日志与第一轮修复资格 - -## 输入与不可变边界 - -- 分支 HEAD:`e4f6102f11be2755d5c6917b09f9fafee9a568dc`,工作区 `D:/Dev/worktrees/openless/issue-1338-backend-hang`。不重建分支、不改原克隆;不混入 #1401 手改学词入口问题。 -- 只读输入:`openless-2026-11-07T12-24-42.log`,12236 行、1623785 字节。经逐行比较,前 11995 行与首份日志完全一致,新增 241 行。普通应用日志的格式有效;不因不是 NDJSON 而拒绝分析。 -- 用户反馈两次异常均发生在操作设置中的选区助手和「问任何问题」期间。这是用户报告的相关场景,不是已证实触发器、锁环或原因;准确故障时刻、现场安装 SHA、退出方式仍未知。 -- 本附件没有 `issue-1338-20261106` 记录;附件目录也没有收到对应 NDJSON。开发机器的约定路径不存在该文件,不将开发机器状态等同于报告者设备状态。 -- 唯一运行时会话日志保持 `%LOCALAPPDATA%/OpenLess/diagnostics/openless-issue-1338-20261106-session.ndjson`,不改名、不截断、不删旧记录;本地不初始化或运行应用。 -- 保留既有独立审查→提交→fork push→准确 head Actions 的门禁。用户已要求暂停因 ARM 容量连续失败的 CI 重试,不能把新日志当作授权改 runner 策略;新候选的执行范围需明确区分旧失败重跑与验证新代码。所有测试、依赖安装、编译、打包仍只用 Actions。 - -## 正常行为与语义检查 - -沿用原报告 N1(快捷键功能)、N2(设置/配置请求完成)、N3(权限状态)、N4(退出)、N5(局部进展不等于全局健康)。补充: - -- N6:进入/结束快捷键录制及保存选区助手/问任何问题设置后,应保留安全的快捷键注册与取消语义,设置保存和其他后端请求可以继续完成。来源:用户期望及现有设置/快捷键接口;具体锁顺序需源码核对,不能先设定未经验证的期望。 -- E1(诊断交付契约,与原卡死根因分开):明确安装诊断构建时,软件内日志导出应能携带本次专用诊断证据或明确告诉用户证据未被捕获,而不是普通日志导出成功被误认为已交付探针。现有 A0 只承诺手动复制专用文件,用户本次确实使用软件内导出;这暴露了需修补的交付可达性缺口,不证明原根因。 - -### 正向记录及边界 - -| 原始行号 | UTC 时间/观察 | 状态与限制 | -|---|---|---| -| 12118–12127 | 11:59:58–12:00:02,一次普通听写进入并出现 PasteSent 局部终态 | 对该后端局部操作 NO_VIOLATION_OBSERVED;不证明用户可见结果或之后全局健康 | -| 12128–12185 | 12:00:25–12:19:57,多次快捷键录制 active=true/false,synced_to_hotkey=true | 对“录制状态切换入口确实到达”有正向证据;没有设置保存、注册 ACK、共享 gate、主线程等待的完整链,N6 仍 INSUFFICIENT_EVIDENCE | -| 12186–12191 | hook/转发线程退出,其后 12:21:06 启动 | 生命周期片段 SUSPICIOUS;不能证明卡死、崩溃或强杀,不能确定与哪次操作对应 | -| 12199–12204 | 12:22:27,选区复制、预取、prefetch_hit 与目标捕获有正向局部成功 | 排除“本次选区从未捕获”作为这次请求的解释;不能证明 QA/助手业务或设置保存完成 | -| 12217–12226 | 12:23:15–17,另一轮普通听写到 PasteSent | 同上,局部完成不等于原报告结果恢复 | -| 12227–12236 | 12:24:20 又启动,随后单实例 relaunch | 不能由启动记录推断前次终止原因;故障发生准确时刻未知 | - -去掉所有 WARN/ERROR 后,用户报告的 N1/N2/N4/N6 功能停滞仍应作为异常观察,但现有 INFO 无法独立确认阻塞位置。语音唤醒模型未安装与旧登录错误没有足够关联,不把它们当作本轮原因。 - -## 原卡死假设与修复资格(本次证据后) - -| ID | Hypothesis | Falsifiable prediction | If true, what change would make the bug disappear? | Probe IDs | -|---|---|---|---|---| -| H1 | 设置/录音生命周期同步路径阻塞原生派发(N2/N6) | 对应设置命令已进入,preferences/warm configure 某阶段不能返回,并有现场线程栈对应 | 只迁移或修正已证实阻塞的同步阶段,保留取消、录音与锁生命周期 | P1/P2/P4 | -| H2 | 原生权限探测或其他同步调用占住 async 执行资源(N2/N3) | 具体原生阶段停滞,线程栈显示执行资源等待;不能仅由权限 Promise.all 未完成推断整体饥饿 | 只调整证实阻塞调用的执行边界,保留权限语义 | P1/P2/P5 | -| H3 | 凭据锁/worker 等待导致配置请求停滞(N2) | worker、锁与请求有真实关联且停在其一;有独立请求对照 | 只修证实的锁持有/worker卡点,保留凭据安全与原子性 | P2/P3 | -| H4 | 选区助手/QA 快捷键设置或注册在共享 host gate 与主线程派发之间形成阻塞(N1/N2/N4/N6) | 相关设置/录制/注册的请求、gate取得/释放、派发与ACK可关联,栈显示具体等待关系 | 只消除已证实的同步等待或锁环,不改变按键顺序、冲突检查、取消或安全边界 | P1/P2/P4/P6(需最小扩展既有类别) | -| H5 | 前端 readiness 阶段未完成,普通后端命令尚未发出(N2/N3/N5) | 同一页面的 startup pending;后端接收缺失不能单独证明未发 | 只修证据定位的 startup 阶段,保留恢复和生命周期屏障 | P1/P2;前端状态未知 | - -五个假设的因果状态与原产品行为修复资格当前均为 `INCONCLUSIVE`。本次尚未取得上一轮 P1–P6 的任何专用记录,不能将用户复现反馈冒充为这些探针的完整运行,更不能声称某个锁环已确认。 - -## 用户后续澄清:主窗口路径异常,业务后端仍可工作 - -**以此澄清覆盖本报告早期“全后端操作停滞”的范围假设。** 用户确认:异常时主窗口像连接不到后端,但语音输入和选区助手使用正常。不能继续将本轮描述成整个后端失活;也不能未经观察就断言 IPC 物理断连。日志 L12199–12204 的选区捕获与 L12217–12226 的普通听写局部完成与此相容,但缺少故障时间关联,不能替代用户的同时性观察。 - -更新契约:N1 对语音/选区的业务链路在该用户报告窗口为 NO_VIOLATION_OBSERVED;N2/N3/N6 的主窗口请求/设置可见结果由用户报告 VIOLATION,具体发送/执行/返回边界仍 INSUFFICIENT_EVIDENCE;N4 本次是否托盘仍无法退出没有新确认。旧 H1–H5 保留为 A0 历史,不以“无NDJSON”强判其成立或否定。 - -本轮活动假设(共四个,不把历史五项当作本轮已确认原因): - -| ID | Hypothesis | Falsifiable prediction | If true, what change would make the bug disappear? | Probe IDs | -|---|---|---|---|---| -| R1-H1 | 主窗口发起的 IPC 已进入/完成后端,但结果未被该页面应用,其他窗口/原生触发不受影响(N2/N3/N5) | 固定分类的 main webview 请求进入与handler返回存在,主窗口Promise/结果应用仍未完成;需前端现场补证,不能把dispatch返回当response到达 | 只修证实的主窗口响应/页面生命周期边界,保留命令权限和取消语义 | P1/P2,前端交付未知 | -| R1-H2 | 主窗口 settings snapshot/readRequest 或 persistQueue 的在途请求未完成,后续设置UI被串行队列连带卡住(N2/N6) | 对应get_settings_snapshot/update_setting_fields/set_settings有进入未返回或页面读写队列pending;独立业务正常 | 只修证实的单个在途设置阶段,不通过清空队列/超时重放掩盖未提交或已提交结果 | P1/P2/P3/P4 | -| R1-H3 | QA/选区快捷键保存或注册等待共享settings_host_gate/主线程ACK,仅阻塞该设置事务而非整个业务后端(N2/N6) | 对应命令/host gate/派发/注册的生成操作链明确停滞,原语音与选区工作作为对照 | 只修证实的事务等待边界或锁关系,保持热键冲突检查、原监听回滚和按键顺序 | P1/P2/P4 | -| R1-H4 | 主窗口页面重载/新实例的readiness或IPC桥状态异常,旧业务链路仍运行(N2/N3/N5) | 需要真实页面实例/ready pending或rejected证据;已fulfilled的同一Promise不会自行再pending,不能把后期异常归因初次启动 | 只修证实的页面/握手生命周期错误,不绕过fail-closed启动契约或加盲目重试 | P1/P2;frontend未知需补证 | - -以上状态与原产品修复资格仍均为 INCONCLUSIVE。已源码核对 `HotkeySettingsContext.tsx:42–68,127–140` 存在读请求复用与串行写队列;`shared.ts:46–63,77–94` 复用readiness;`App.tsx:98–120` 等待初始握手后才挂业务UI。因此后期卡住不能无证据归结为同一已完成的readiness。下一步最小观察扩展是区分 main/other 的真实native invoke来源、补 get_settings_snapshot/update_setting_fields/快捷键设置与共享host gate阶段,不改变业务调度。 - -## 可执行的诊断交付修复与最小观察扩展 - -`commands/misc.rs:266–289` 的软件内 `export_error_log` 仅复制 `openless.log`;A0 专用 writer 独立追加 NDJSON。该实现和用户本次导出路径直接解释为何附件不含专用证据。因此**诊断交付缺口可先修复**,但它不是原卡死根因。 - -交付假设分开判定:D1=内置导出未包含专用源(SOURCE-CONFIRMED/ACTIONABLE);D2=现场未安装/未启用诊断构建(INCONCLUSIVE);D3=writer 未初始化、写入失败或证据丢失(INCONCLUSIVE)。修补后必须让单一导出文件携带当前诊断会话、真实捕获健康、前端 unknown 与尾部 unknown;缺失/失败必须显式呈现,不得拿旧文件或零 drops 伪装完整捕获。 - -补观察仍限定原六类边界,优先扩展 P1/P2 的快捷键录制/设置/选区助手与 QA 请求及 P4 原生注册/派发边界;不添加 P7,不做无限量通用日志。只记录闭合类别、结果、时长、生成操作标签,禁止按键原值、选区文本、配置/凭据、窗口名及原始错误。保留原探针,运行内关联必须跟随实际操作,不能用最近 UI 状态或同线程邻近记录补关联。 - -下一步:源码核对 H4 最小直接路径;如仍无 ACTIONABLE 原行为修复,明确保留该阻塞并请求现有专用文件(不先要求用户重新复现),同时修复诊断导出并交付新增边界观察的包。不能将“日志更完整/构建通过”宣告为原卡死已修复。 diff --git a/docs/research/issue-1338-feedback-20261108.md b/docs/research/issue-1338-feedback-20261108.md deleted file mode 100644 index c54d0bd84..000000000 --- a/docs/research/issue-1338-feedback-20261108.md +++ /dev/null @@ -1,34 +0,0 @@ -# #1338 A2:点击语音编辑「打开面板」后的修复尝试 - -## 证据与范围 - -- 只读附件 `openless-2026-11-08T11-41-24-2.log`,108813614字节;SHA256 `cd1a2e1d15a4759fa7e1fef06672154bf21feef2c95881ee5cd1643d87c4de33`,与无`-2`附件哈希一致。禁止改写附件。 -- 包含8个运行的历史诊断,不能把相邻运行拼成因果链。A1构建`b743c50abc2d9e04f59eaeee2fa3e124ba470331`确实运行过。最终checkpoint属于重启后的`r18e60f1163561fa8`,不是故障候选运行的完整性担保;前端发送、应用与尾部捕获仍unknown。 -- 用户明确反馈:点击设置中语音编辑会话右侧「打开面板」必现。此前确认异常时语音输入和选区助手仍可用。此为用户报告的稳定触发/结果,不伪称开发机已复现。 -- `r18e60f01dcfc6bec`:L119910–119917有close_requested/destroyed,窗口类别未记录,不能断定关闭了哪个窗口。L119924–119955 P4操作237/240/243/246记录requested与dispatch_returned=ok,无entered/returned;对应P2父操作236/239/242/245仍acquired/released,后台继续运行。记录期内dropped计数0,不等于该历史运行尾部完整。语义SUSPICIOUS,不能宣称已证明整个主线程永久死锁。 -- 目前命令不在P1 allowlist,缺少本次点击与上述历史运行的直接operation关联;补对应入口/创建/显示边界,不伪造旧记录关联。 - -## 正常契约与语义检查 - -- N7:主窗口点击打开面板后,应完成面板创建/显示并保留主窗口IPC可用;voice-edit session仍由面板后续start_voice_edit_session启动,非main调用保持拒绝。来源:用户期望、SelectionWorkspaceSection.tsx、commands/voice_edit.rs注释及window标签校验。用户报告VIOLATION;旧日志具体点击阶段INSUFFICIENT_EVIDENCE。 -- N8:接受的主线程任务不应因窗口创建重入而使后续工作无进展;派发成功只证明入队,不证明执行。来源:Tauri宿主派发契约。上述P4为SUSPICIOUS,重启/关闭及尾部截断替代解释未排除。 -- N2原设置/权限请求应继续完成;用户报告VIOLATION,现场未直接记录每个页面Promise。 -- E1:A1内置导出携带专用NDJSON和当前checkpoint已观测成立(L120835);NO_VIOLATION_OBSERVED,仅限这次导出,不代表原故障修复。 - -## 修复前假设 - -| ID | Hypothesis | Falsifiable prediction | If true, what change would make the bug disappear? | Probe IDs | -|---|---|---|---|---| -| R2-H1 | Windows同步IPC命令中懒创建WebView发生重入/同步等待(N7/N8) | 打开命令进入后创建阶段无返回;异步分派同一命令后创建、显示返回且主窗口请求恢复 | 仅把voice_edit_window_open改async,不变校验、会话、窗口参数 | P1/P2/P4 | -| R2-H2 | 窗口已创建,Windows focus/show阶段阻塞(N7/N8) | 创建returned而focus或show无returned;H1变更后同样停在该阶段 | 仅调整证实的显示阶段执行边界 | P2/P4 | -| R2-H3 | 窗口关闭/销毁后的宿主事件队列生命周期异常(N8) | 对应窗口生命周期与后续任务不执行相关,创建显示此前完成;需固定窗口类别观察 | 仅修证实的宿主句柄生命周期,不重建整个后端 | P4/P6 | -| R2-H4 | 后端完成打开而主窗口前端readiness/请求应用挂起(N2/N7) | 创建显示完整返回,原主窗口页面Promise仍挂起;需前端补证 | 仅修已定位页面/桥生命周期,不盲目重试 | P1/P2/P4;frontend仍unknown | - -## 修复资格与单变量实验 - -- 锁定依赖为src-tauri/Cargo.lock中的Tauri2.12.4。官方对应tag源码:`https://raw.githubusercontent.com/tauri-apps/tauri/tauri-v2.12.4/crates/tauri/src/webview/webview_window.rs` L58–59、L115–116明确警告Windows同步命令/事件处理器创建WebviewWindow会deadlock,并推荐async command或独立线程。 -- 当前实际路径:SelectionWorkspaceSection点击 -> openVoiceEditWindow -> voice_edit_window_open(同步)-> TauriCoordinatorHost::show_voice_edit -> show_voice_edit_window -> ensure_voice_edit_window -> WebviewWindowBuilder::build。主窗口标签限制仍保留。 -- R2-H1=INCONCLUSIVE causal confidence / ACTIONABLE reversible experiment:用户稳定触发 + 锁定API已知重入约束 + 现场主线程派发无进展而后台锁仍运行,支持针对这一条边界做单变量实验;不是宣称其他原因已排除。R2-H2/H3/H4仍INCONCLUSIVE。 -- attempt_id=A2-voice-edit-open-async;change_id=C2-async-command-dispatch。仅命令sync->async改变业务执行边界;增加既有P1/P2/P4内的闭合阶段,不新增P7,不改超时/重试/锁顺序/窗口参数/权限/会话提交。 -- 测试先编写,运行仅Actions;未执行本地测试、编译、安装或应用启动,不声称观察到red/green。 -- 下一门禁:完成候选→review-diff-bugs完整复审至no bug found→提交/push fork→准确SHA Actions CI/桌面/移动端→交付新包固定触发复现。继续同一个session NDJSON,保留历史;用户确认修复前不清理。 diff --git a/openless-all/app/scripts/backend-hang-workflow.test.mjs b/openless-all/app/scripts/backend-hang-workflow.test.mjs deleted file mode 100644 index 3c281d9dc..000000000 --- a/openless-all/app/scripts/backend-hang-workflow.test.mjs +++ /dev/null @@ -1,53 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import yaml from 'js-yaml'; - -const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8').replace(/\r/g, ''); -const release = yaml.load(read('../../../.github/workflows/release-tauri.yml')); -const ci = yaml.load(read('../../../.github/workflows/ci.yml')); -const inputs = release.on.workflow_dispatch.inputs; -assert.equal(inputs.windows_backend_diagnostics.default, false); -assert.equal(inputs.windows_hang_diagnostics.default, false); -const gate = release.jobs.build.env.OPENLESS_BACKEND_DIAGNOSTICS; -for (const fragment of [ - 'workflow_dispatch', - 'inputs.windows_backend_diagnostics', - 'windows-latest', - 'refs/heads/', - "inputs.release_tag == ''", -]) { - assert.ok(gate.includes(fragment)); -} -const steps = release.jobs.build.steps; -const guard = steps.find((step) => step.name === 'Validate independent diagnostic sessions'); -assert.ok(guard); -assert.ok(steps.indexOf(guard) < steps.findIndex((step) => step.name === 'Build (Windows)')); -assert.match(guard.run, /LEGACY_DIAGNOSTICS.*!= true/); -assert.match(guard.run, /-z "\$INPUT_RELEASE_TAG"/); -assert.match(guard.run, /refs\/heads\/\*/); -assert.match(guard.run, /INPUT_PLATFORM.*!= macos/); -assert.equal( - steps.find((step) => step.uses === 'actions/checkout@v4').with.ref, - '${{ inputs.release_tag || github.sha }}', -); -const build = steps.find((step) => step.name === 'Build (Windows)').run; -assert.match(build, /diagnostic_args=\(--features issue-1338-diagnostics\)/); -assert.match(build, /backend_observation=\$OPENLESS_BACKEND_DIAGNOSTICS/); -assert.equal((build.match(/build "\$\{diagnostic_args\[@\]\}" --bundles/g) ?? []).length, 4); -const provenance = steps.find((step) => step.name === 'Write Windows artifact provenance').run; -assert.match(provenance, /issue_1338_diagnostics = \(\$env:OPENLESS_BACKEND_DIAGNOSTICS -eq 'true'\)/); -assert.match(provenance, /issue-1338-20261106/); -const upload = steps.find((step) => step.name === 'Upload Windows artifacts'); -assert.match(upload.with.name, /openless-windows-x64-issue-1338-diagnostics/); -assert.match(upload.with.path, /openless-provenance\.json/); -assert.match(upload.with.path, /openless-symbol-pair\.json/); -// Both observation modes remain branch-artifact paths, never publishing conditions. -assert.doesNotMatch(release.jobs.publish.if, /diagnostics/); -assert.match(release.jobs.publish.if, /refs\/tags\/v/); -assert.match(release.jobs.publish.if, /inputs.release_tag != ''/); -const ciSteps = Object.values(ci.jobs).flatMap((job) => job.steps ?? []); -assert.ok( - ciSteps.some((step) => /cargo check.*--features issue-1338-diagnostics/.test(step.run ?? '')), -); -assert.ok(ciSteps.some((step) => /test-backend-hang-diagnostics\.py/.test(step.run ?? ''))); -console.log('backend observation branch-build workflow contracts passed'); diff --git a/openless-all/app/scripts/test-backend-hang-diagnostics.py b/openless-all/app/scripts/test-backend-hang-diagnostics.py deleted file mode 100644 index e711c8155..000000000 --- a/openless-all/app/scripts/test-backend-hang-diagnostics.py +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env python3 -"""Actions-only #1338 harness importing the production module, without Tauri/native DLLs. - -The real Windows writer is compiled by the feature-on host check. Unit tests use -injected memory/failing writers, never the user's session file. The facade binary -also executes the non-test, disabled path (including feature-on macOS). -""" -import json -from pathlib import Path -import subprocess -import tempfile - -source = Path(__file__).resolve().parents[1] / "src-tauri/src/issue1338_debug.rs" -# Source-contract layer only: the Windows diagnostic command is asynchronous; -# standard builds still use the original synchronous copy/Android SAF branch. -# Runtime behavior is covered separately by the injected Rust module tests. -misc = (source.parent / "commands/misc.rs").read_text(encoding="utf-8").replace("\r\n", "\n") -start = misc.index('#[cfg(all(target_os = "windows", feature = "issue-1338-diagnostics"))]\n#[tauri::command]\npub async fn export_error_log(') -standard = misc.index('#[cfg(not(all(target_os = "windows", feature = "issue-1338-diagnostics")))]\n#[tauri::command]\npub fn export_error_log(', start) -end = misc.index('/// Android:直接把当前会话日志写入公共 Downloads', standard) -diagnostic_export = misc[start:standard] -standard_export = misc[standard:end] -assert 'spawn_blocking(move ||' in diagnostic_export -assert 'crate::issue1338_debug::export_log(' in diagnostic_export -assert 'std::fs::copy' not in diagnostic_export -assert 'std::fs::copy(&src, std::path::Path::new(&target_path))' in standard_export -assert 'write_content_uri(&target_path, &bytes)' in standard_export -assert 'issue1338_debug' not in standard_export.split('pub fn export_error_log(', 1)[1] -with tempfile.TemporaryDirectory(prefix="openless-backend-hang-tests-") as directory: - root = Path(directory) - (root / "src/bin").mkdir(parents=True) - (root / "Cargo.toml").write_text('''[package] -name = "openless-backend-hang-tests" -version = "0.1.0" -edition = "2021" -[features] -issue-1338-diagnostics = [] -[dependencies] -parking_lot = "0.12" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -tempfile = "3" -''', encoding="utf-8") - module = "#[path = " + json.dumps(str(source), ensure_ascii=False) + "]\nmod issue1338_debug;\n" - (root / "src/lib.rs").write_text(module, encoding="utf-8") - (root / "src/bin/facade.rs").write_text(module + '''fn main() { - if !issue1338_debug::enabled() { - issue1338_debug::initialize(); - let health = serde_json::to_value(issue1338_debug::health()).unwrap(); - assert_eq!(health["initialized"], false); - assert_eq!(health["ready"], false); - let op = issue1338_debug::Operation::new(issue1338_debug::Probe::P1); - op.mark(issue1338_debug::Event::Requested); - } -} -''', encoding="utf-8") - for features in ([], ["--features", "issue-1338-diagnostics"]): - for command in (["test", "--lib"], ["run", "--bin", "facade"]): - subprocess.run(["cargo", *command, "--manifest-path", str(root / "Cargo.toml"), *features], check=True) diff --git a/openless-all/app/scripts/test-recorder-module.py b/openless-all/app/scripts/test-recorder-module.py index ce24f6937..62bd56df0 100644 --- a/openless-all/app/scripts/test-recorder-module.py +++ b/openless-all/app/scripts/test-recorder-module.py @@ -27,7 +27,6 @@ parking_lot = "0.12" serde = { version = "1", features = ["derive"] } serde_json = "1" -tempfile = "3" thiserror = "1" log = "0.4" """ + "cpal = { version = \"=0.15.3\", path = " + json.dumps((recorder.parents[1] / "vendor/cpal").as_posix()) + " }\n" @@ -52,7 +51,6 @@ "pub use audio::{pcm_f32_to_i16_le, AudioProcessingConfig, AudioProcessor, FarEndQueue, ReferenceAudioBuffer};\n" "pub trait AudioConsumer: Send + Sync { fn consume_pcm_chunk(&self, pcm: &[u8]); }\n" + "#[path = " + json.dumps(str(recorder.with_name("issue1266_debug.rs")), ensure_ascii=False) + "]\nmod issue1266_debug;\n" - + "#[path = " + json.dumps(str(recorder.with_name("issue1338_debug.rs")), ensure_ascii=False) + "]\nmod issue1338_debug;\n" + "#[path = " + json.dumps(str(recorder), ensure_ascii=False) + "]\npub mod recorder;\n" + "#[path = " + json.dumps(str(recorder.with_name("native_audio_cue.rs")), ensure_ascii=False) + "]\npub mod native_audio_cue;\n" ) diff --git a/openless-all/app/scripts/voice-edit-open-dispatch-contract.test.mjs b/openless-all/app/scripts/voice-edit-open-dispatch-contract.test.mjs index b59055df1..e800295f3 100644 --- a/openless-all/app/scripts/voice-edit-open-dispatch-contract.test.mjs +++ b/openless-all/app/scripts/voice-edit-open-dispatch-contract.test.mjs @@ -1,14 +1,13 @@ // Regression contract for the Windows WebView-creation dispatch boundary. // This source contract cannot reproduce a native event-loop deadlock: Actions -// must also compile the real host, and the affected device must verify N7/N8. +// must also compile the real host. Source checks are not native installation acceptance. import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; const read = (relative) => readFileSync(new URL(relative, import.meta.url), 'utf8').replace(/\r\n/g, '\n'); const command = read('../src-tauri/src/commands/voice_edit.rs'); -const diagnostics = read('../src-tauri/src/issue1338_debug.rs'); -const host = read('../src-tauri/src/lib.rs'); + function openCommand(source) { const start = source.indexOf('pub async fn voice_edit_window_open('); @@ -23,18 +22,21 @@ test('voice edit creation leaves the synchronous IPC handler and retains main-on assert.match(body, /window\.label\(\) != "main"/); assert.match(body, /return Err\("Voice Edit can only be opened from the main window"\.to_string\(\)\)/); assert.ok(body.indexOf('return Err(') < body.indexOf('show_voice_edit()')); - assert.match(body, /observe_future\([\s\S]*Kind::VoiceEditOpen[\s\S]*async move/); + assert.match(command, /#\[cfg\(target_os = "windows"\)\]\s*#\[tauri::command\]\s*pub async fn voice_edit_window_open\(/); assert.match(body, /show_voice_edit\(\);[\s\S]*Ok\(\(\)\)/); assert.doesNotMatch(body, /start_voice_edit_session\(/, 'opening must not start or commit a session'); }); -test('diagnostic source preserves the specific open and native-stage boundaries', () => { - assert.match(diagnostics, /"voice_edit_window_open"\s*=>\s*Some\(Self::VoiceEditOpen\)/); - for (const kind of ['VoiceEditLookup', 'VoiceEditCreate', 'VoiceEditFocusable', 'VoiceEditShow']) { - assert.ok(host.includes(`Kind::${kind}`), `${kind} must observe the real native stage`); - } - assert.match(diagnostics, /request_intent": "unknown"/); - assert.match(diagnostics, /response_application": "unknown"/); +test('non-Windows keeps its synchronous command and main-only presentation contract', () => { + assert.match(command, /#\[cfg\(not\(target_os = "windows"\)\)\]\s*#\[tauri::command\]\s*pub fn voice_edit_window_open\(/); + const start = command.indexOf('pub fn voice_edit_window_open('); + const end = command.indexOf('\n#[tauri::command]', start); + assert.ok(start >= 0 && end > start); + const body = command.slice(start, end); + assert.match(body, /window\.label\(\) != "main"/); + assert.ok(body.indexOf('return Err(') < body.indexOf('show_voice_edit()')); + assert.match(body, /show_voice_edit\(\);\s*Ok\(\(\)\)/); + assert.doesNotMatch(body, /start_voice_edit_session\(|commit_voice_edit/); }); for (const newline of ['\n', '\r\n']) { @@ -43,3 +45,10 @@ for (const newline of ['\n', '\r\n']) { assert.ok(openCommand(normalized).includes('show_voice_edit()')); }); } + +test('missing boundaries fail and the following unrelated command is excluded', () => { + assert.throws(() => openCommand('pub fn voice_edit_window_open() {}')); + assert.throws(() => openCommand('pub async fn voice_edit_window_open() {}')); + const fixture = 'pub async fn voice_edit_window_open() {}\n#[tauri::command]\npub fn unrelated() { commit_voice_edit(); }'; + assert.doesNotMatch(openCommand(fixture), /commit_voice_edit/); +}); diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index a2b5f2030..7b52de55a 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -189,7 +189,6 @@ window-vibrancy = "0.7" default = ["custom-protocol"] custom-protocol = ["tauri/custom-protocol"] issue-1266-diagnostics = [] -issue-1338-diagnostics = [] [profile.release] # 之前没有 [profile.release] —— 发布版走 Cargo 默认(无 LTO、不 strip、 diff --git a/openless-all/app/src-tauri/backend-tests/tests/audio_processing.rs b/openless-all/app/src-tauri/backend-tests/tests/audio_processing.rs index 3e1b5e32f..213a9da86 100644 --- a/openless-all/app/src-tauri/backend-tests/tests/audio_processing.rs +++ b/openless-all/app/src-tauri/backend-tests/tests/audio_processing.rs @@ -2,7 +2,5 @@ #![allow(dead_code)] #[path = "../../src/issue1266_debug.rs"] mod issue1266_debug; -#[path = "../../src/issue1338_debug.rs"] -mod issue1338_debug; #[path = "../../src/recorder.rs"] mod recorder; diff --git a/openless-all/app/src-tauri/src/commands/channels.rs b/openless-all/app/src-tauri/src/commands/channels.rs index da193d5bb..b927aed5c 100644 --- a/openless-all/app/src-tauri/src/commands/channels.rs +++ b/openless-all/app/src-tauri/src/commands/channels.rs @@ -23,12 +23,10 @@ pub async fn list_channels( window: Window, kind: String, ) -> Result, String> { - crate::issue1338_debug::observe_future(crate::issue1338_debug::Kind::ListChannels, async move { - ensure_main_window(&window)?; - core.list_channels(parse_kind(&kind)?) - .await - .map_err(|error| error.to_string()) - }).await + ensure_main_window(&window)?; + core.list_channels(parse_kind(&kind)?) + .await + .map_err(|error| error.to_string()) } #[tauri::command] diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index 6ada12f91..2d073e7b9 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -318,19 +318,8 @@ impl openless_core::CredentialStore for SystemCredentialStore { fn run_credential_task( task: impl FnOnce() -> Result + Send + 'static, ) -> futures_util::future::BoxFuture<'static, Result> { - let operation = crate::issue1338_debug::Operation::new(crate::issue1338_debug::Probe::P2); Box::pin(async move { - operation.record(crate::issue1338_debug::Event::Requested, crate::issue1338_debug::Outcome::Observed, - crate::issue1338_debug::Facts { kind: crate::issue1338_debug::Kind::CredentialWorker, ..Default::default() }); - tauri::async_runtime::spawn_blocking(move || { - use crate::issue1338_debug::{Boundary, Kind, SyncContext}; - let _context = SyncContext::enter(operation); - let boundary = Boundary { operation, kind: Kind::CredentialWorker }; - boundary.record(crate::issue1338_debug::Event::Entered, crate::issue1338_debug::Outcome::Observed); - let result = task(); - boundary.returned(); - result - }) + tauri::async_runtime::spawn_blocking(task) .await .map_err(|_| { openless_core::BackendError::new( @@ -917,13 +906,11 @@ pub async fn read_credential( account: String, provider: Option, ) -> Result, String> { - crate::issue1338_debug::observe_future(crate::issue1338_debug::Kind::ReadCredential, async move { - ensure_main_window(&window)?; - core.read_credential(credential_key(&account, provider)?) - .await - .map(|value| value.map(openless_core::SecretValue::into_exposed)) - .map_err(|error| error.to_string()) - }).await + ensure_main_window(&window)?; + core.read_credential(credential_key(&account, provider)?) + .await + .map(|value| value.map(openless_core::SecretValue::into_exposed)) + .map_err(|error| error.to_string()) } fn credential_key( diff --git a/openless-all/app/src-tauri/src/commands/dictation.rs b/openless-all/app/src-tauri/src/commands/dictation.rs index 21f4ca793..d0f11b05f 100644 --- a/openless-all/app/src-tauri/src/commands/dictation.rs +++ b/openless-all/app/src-tauri/src/commands/dictation.rs @@ -4,9 +4,7 @@ use super::*; pub async fn get_startup_snapshot( core: CoreState<'_>, ) -> Result { - crate::issue1338_debug::observe_future(crate::issue1338_debug::Kind::StartupSnapshot, async move { - core.start().await.map_err(|error| error.to_string()) - }).await + core.start().await.map_err(|error| error.to_string()) } #[cfg(not(mobile))] diff --git a/openless-all/app/src-tauri/src/commands/hotkeys.rs b/openless-all/app/src-tauri/src/commands/hotkeys.rs index f8cec938e..3bd555ac8 100644 --- a/openless-all/app/src-tauri/src/commands/hotkeys.rs +++ b/openless-all/app/src-tauri/src/commands/hotkeys.rs @@ -106,10 +106,6 @@ pub fn set_selection_polish_hotkey( coord: CoordinatorState<'_>, binding: Option, ) -> Result<(), String> { - use crate::issue1338_debug::{Boundary, Event, Kind, Outcome, Probe, SyncContext}; - let boundary = Boundary::enter(Probe::P2, Kind::SelectionHotkeySettings); - let _context = SyncContext::enter(boundary.operation); - let result = (|| { if let Some(binding) = binding.as_ref() { crate::shortcut_binding::validate_binding(binding).map_err(|e| e.to_string())?; crate::shortcut_binding::reject_side_specific_non_dictation(binding)?; @@ -119,9 +115,6 @@ pub fn set_selection_polish_hotkey( next.selection_polish_hotkey = binding; reject_hotkey_collisions(&next)?; super::settings::persist_strict_settings(&coord, next) - })(); - boundary.record(Event::Returned, if result.is_ok() { Outcome::Ok } else { Outcome::Error }); - result } /// Replaces the whole style-pack direct-hotkey table (issue #759). The frontend sends the full list on any diff --git a/openless-all/app/src-tauri/src/commands/misc.rs b/openless-all/app/src-tauri/src/commands/misc.rs index ab3ead231..a722dfbc2 100644 --- a/openless-all/app/src-tauri/src/commands/misc.rs +++ b/openless-all/app/src-tauri/src/commands/misc.rs @@ -82,39 +82,24 @@ pub async fn get_hotkey_status(core: CoreState<'_>) -> Result) -> HotkeyCapability { - use crate::issue1338_debug::{Boundary, Kind, Probe, SyncContext}; - let boundary = Boundary::enter(Probe::P2, Kind::GetHotkeyCapability); - let _context = SyncContext::enter(boundary.operation); #[cfg(mobile)] { let _ = coord; return HotkeyCapability::current(); } #[cfg(not(mobile))] - { - let result = coord.hotkey_capability(); - boundary.returned(); - result - } + coord.hotkey_capability() } #[tauri::command] pub fn set_shortcut_recording_active(coord: CoordinatorState<'_>, active: bool) { - use crate::issue1338_debug::{Boundary, Event, Facts, Kind, Outcome, Probe, SyncContext}; - let boundary = Boundary::enter(Probe::P2, Kind::ShortcutRecording); - let _context = SyncContext::enter(boundary.operation); - boundary.operation.record(Event::Requested, Outcome::Observed, - Facts { kind: Kind::ShortcutRecording, flag: active, ..Default::default() }); #[cfg(mobile)] { let _ = (coord, active); return; } #[cfg(not(mobile))] - { - coord.set_shortcut_recording_active(active); - boundary.returned(); - } + coord.set_shortcut_recording_active(active); } #[tauri::command] @@ -277,16 +262,6 @@ pub async fn stop_microphone_level_monitor(app: AppHandle) { /// On Android the dialog returns a `content://` URI which `std::fs::copy` cannot handle; write /// through the JNI ContentResolver instead, avoiding the 0-byte files caused by /// tauri-plugin-fs's detachFd. -#[cfg(all(target_os = "windows", feature = "issue-1338-diagnostics"))] -#[tauri::command] -pub async fn export_error_log(target_path: String) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { - let src = resolve_openless_log_path()?; - crate::issue1338_debug::export_log(&src, std::path::Path::new(&target_path)) - }).await.map_err(|_| "issue1338_export_worker_failed: capture unknown".to_owned())? -} - -#[cfg(not(all(target_os = "windows", feature = "issue-1338-diagnostics")))] #[tauri::command] pub fn export_error_log(target_path: String) -> Result<(), String> { let src = resolve_openless_log_path()?; diff --git a/openless-all/app/src-tauri/src/commands/permissions_cmds.rs b/openless-all/app/src-tauri/src/commands/permissions_cmds.rs index 0fca08dce..1b852af5e 100644 --- a/openless-all/app/src-tauri/src/commands/permissions_cmds.rs +++ b/openless-all/app/src-tauri/src/commands/permissions_cmds.rs @@ -97,15 +97,13 @@ pub fn open_external_url(url: String) -> Result<(), String> { pub async fn check_accessibility_permission( core: CoreState<'_>, ) -> Result { - crate::issue1338_debug::observe_future(crate::issue1338_debug::Kind::Accessibility, async move { - Ok(core - .services() - .platform - .accessibility_permission() - .await - .map(|snapshot| map_core_permission(snapshot.accessibility)) - .unwrap_or(PermissionStatus::NotApplicable)) - }).await + Ok(core + .services() + .platform + .accessibility_permission() + .await + .map(|snapshot| map_core_permission(snapshot.accessibility)) + .unwrap_or(PermissionStatus::NotApplicable)) } #[tauri::command] @@ -132,15 +130,13 @@ pub async fn request_accessibility_permission( #[tauri::command] pub async fn check_microphone_permission(core: CoreState<'_>) -> Result { - crate::issue1338_debug::observe_future(crate::issue1338_debug::Kind::Microphone, async move { - Ok(core - .services() - .platform - .microphone_permission() - .await - .map(|snapshot| map_core_permission(snapshot.microphone)) - .unwrap_or(PermissionStatus::NotApplicable)) - }).await + Ok(core + .services() + .platform + .microphone_permission() + .await + .map(|snapshot| map_core_permission(snapshot.microphone)) + .unwrap_or(PermissionStatus::NotApplicable)) } #[tauri::command] diff --git a/openless-all/app/src-tauri/src/commands/qa.rs b/openless-all/app/src-tauri/src/commands/qa.rs index 51fb1d8cb..b4e081e8d 100644 --- a/openless-all/app/src-tauri/src/commands/qa.rs +++ b/openless-all/app/src-tauri/src/commands/qa.rs @@ -20,10 +20,6 @@ pub fn set_qa_hotkey( coord: CoordinatorState<'_>, binding: Option, ) -> Result<(), String> { - use crate::issue1338_debug::{Boundary, Event, Kind, Outcome, Probe, SyncContext}; - let boundary = Boundary::enter(Probe::P2, Kind::QaHotkeySettings); - let _context = SyncContext::enter(boundary.operation); - let result = (|| { if binding .as_ref() .is_some_and(|binding| !binding.modifiers.is_empty()) @@ -35,9 +31,6 @@ pub fn set_qa_hotkey( prefs.qa_companion_key = binding.map(|binding| binding.primary).unwrap_or_default(); reject_hotkey_collisions(&prefs)?; super::settings::persist_strict_settings(&coord, prefs) - })(); - boundary.record(Event::Returned, if result.is_ok() { Outcome::Ok } else { Outcome::Error }); - result } /// User clicks ✕ or presses Esc to close the QA panel. diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 51c8f8a2d..bf1991cdc 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -22,19 +22,9 @@ pub fn set_android_audio_monitoring(enabled: bool) { #[tauri::command] pub fn get_settings(core: CoreState<'_>) -> UserPreferences { - use crate::issue1338_debug::{Boundary, Kind, Probe, SyncContext}; - let command = Boundary::enter(Probe::P2, Kind::GetSettings); - let _context = SyncContext::enter(command.operation); - let read = Boundary::enter(Probe::P2, Kind::PrefsRead); let mut prefs = core.get_preferences(); - read.returned(); - drop(read); prefs.update_channel = effective_update_channel(None, &prefs, env!("CARGO_PKG_VERSION")); - let configure = Boundary::enter(Probe::P2, Kind::WarmConfigure); sync_low_latency_capture_preferences(&prefs); - configure.returned(); - drop(configure); - command.returned(); prefs } @@ -326,7 +316,6 @@ pub async fn set_settings( ) -> Result { // Capture old values for the remote-input service diff (start/stop/restart when the // port/switch changes after persist). - crate::issue1338_debug::observe_future(crate::issue1338_debug::Kind::SetSettings, async move { let remote_prev = coord.backend().get_preferences(); if let Some(edits) = &edits { prefs = openless_core::preference_patch::patch_preferences(&prefs, edits) @@ -400,7 +389,6 @@ pub async fn set_settings( .map_err(|error| error.message)?; } Ok(prefs) - }).await } #[cfg(mobile)] @@ -950,30 +938,14 @@ pub struct SettingsSnapshot { #[tauri::command] pub fn get_settings_snapshot(core: CoreState<'_>) -> Result { - use crate::issue1338_debug::{Boundary, Event, Kind, Outcome, Probe, SyncContext}; - let boundary = Boundary::enter(Probe::P2, Kind::SettingsSnapshot); - let _context = SyncContext::enter(boundary.operation); - let result = settings_snapshot(&core); - boundary.record(Event::Returned, if result.is_ok() { Outcome::Ok } else { Outcome::Error }); - result + settings_snapshot(&core) } fn settings_snapshot(backend: &openless_core::OpenLessBackend) -> Result { for _ in 0..3 { - use crate::issue1338_debug::{Boundary, Kind, Probe}; - let state_read = Boundary::enter(Probe::P2, Kind::SnapshotRevision); let revision = backend.snapshot().preferences_revision; - state_read.returned(); - drop(state_read); - let prefs_read = Boundary::enter(Probe::P2, Kind::SnapshotPreferences); let preferences = backend.get_preferences(); - prefs_read.returned(); - drop(prefs_read); - let verify = Boundary::enter(Probe::P2, Kind::SnapshotVerify); - let matches = backend.snapshot().preferences_revision == revision; - verify.returned(); - drop(verify); - if matches { + if backend.snapshot().preferences_revision == revision { return Ok(SettingsSnapshot { preferences, revision, @@ -1016,11 +988,9 @@ pub async fn update_setting_fields( app: AppHandle, edits: std::collections::BTreeMap, ) -> Result { - crate::issue1338_debug::observe_future(crate::issue1338_debug::Kind::UpdateSettingFields, async move { let prefs = coord.backend().get_preferences(); set_settings(coord.clone(), app, prefs, Some(edits)).await?; settings_snapshot(&coord.backend()) - }).await } #[cfg(mobile)] diff --git a/openless-all/app/src-tauri/src/commands/voice_edit.rs b/openless-all/app/src-tauri/src/commands/voice_edit.rs index e1fc5c0bc..370ae4a3b 100644 --- a/openless-all/app/src-tauri/src/commands/voice_edit.rs +++ b/openless-all/app/src-tauri/src/commands/voice_edit.rs @@ -3,21 +3,19 @@ use super::CoordinatorState; /// Opens the desktop Voice Edit panel or asks Android's main WebView to show /// its embedded equivalent. The session starts only after the panel invokes /// `start_voice_edit_session`. -// Tauri 2.12.4 warns against Windows WebView creation in synchronous IPC. -// [DEBUG-ISSUE-1338] A2 changes only this command's dispatch boundary. +// WebView2 creation from a synchronous IPC callback can deadlock on Windows. +// An async command lets that callback return before creating the panel. #[cfg(target_os = "windows")] #[tauri::command] pub async fn voice_edit_window_open( window: tauri::Window, coord: CoordinatorState<'_>, ) -> Result<(), String> { - crate::issue1338_debug::observe_future(crate::issue1338_debug::Kind::VoiceEditOpen, async move { - if window.label() != "main" { - return Err("Voice Edit can only be opened from the main window".to_string()); - } - coord.tauri_host().show_voice_edit(); - Ok(()) - }).await + if window.label() != "main" { + return Err("Voice Edit can only be opened from the main window".to_string()); + } + coord.tauri_host().show_voice_edit(); + Ok(()) } #[cfg(not(target_os = "windows"))] diff --git a/openless-all/app/src-tauri/src/issue1266_debug.rs b/openless-all/app/src-tauri/src/issue1266_debug.rs index edae44d7e..5c53240b9 100644 --- a/openless-all/app/src-tauri/src/issue1266_debug.rs +++ b/openless-all/app/src-tauri/src/issue1266_debug.rs @@ -178,9 +178,7 @@ pub(crate) struct HostGuard<'a> { operation: Operation, kind: Kind, } -#[cfg(all(target_os = "windows", feature = "issue-1338-diagnostics", not(feature = "issue-1266-diagnostics")))] -pub(crate) type HostGuard<'a> = crate::issue1338_debug::Guard<'a, ()>; -#[cfg(not(all(target_os = "windows", any(feature = "issue-1266-diagnostics", feature = "issue-1338-diagnostics"))))] +#[cfg(not(all(target_os = "windows", feature = "issue-1266-diagnostics")))] pub(crate) type HostGuard<'a> = parking_lot::MutexGuard<'a, ()>; pub(crate) fn lock_host(mutex: &parking_lot::Mutex<()>, kind: Kind) -> HostGuard<'_> { @@ -192,20 +190,7 @@ pub(crate) fn lock_host(mutex: &parking_lot::Mutex<()>, kind: Kind) -> HostGuard operation.record(Event::Acquired, Outcome::Observed, Facts { kind, ..Facts::default() }); HostGuard { guard: Some(guard), operation, kind } } - // [DEBUG-ISSUE-1338] Observe the same gate for commands and registration - // supervisors without changing ownership, lock ordering, or retry policy. - #[cfg(all(target_os = "windows", feature = "issue-1338-diagnostics", not(feature = "issue-1266-diagnostics")))] - { - let trace_kind = match kind { - Kind::Settings => crate::issue1338_debug::Kind::SettingsHostGate, - Kind::SelectionHotkey => crate::issue1338_debug::Kind::SelectionHotkeyRegistration, - Kind::QaHotkey => crate::issue1338_debug::Kind::QaHotkeyRegistration, - _ => crate::issue1338_debug::Kind::OtherHotkeyRegistration, - }; - crate::issue1338_debug::lock_at(mutex, crate::issue1338_debug::Probe::P2, - trace_kind) - } - #[cfg(not(all(target_os = "windows", any(feature = "issue-1266-diagnostics", feature = "issue-1338-diagnostics"))))] + #[cfg(not(all(target_os = "windows", feature = "issue-1266-diagnostics")))] { let _ = kind; mutex.lock() } } diff --git a/openless-all/app/src-tauri/src/issue1338_debug.rs b/openless-all/app/src-tauri/src/issue1338_debug.rs deleted file mode 100644 index 11a9fe6ab..000000000 --- a/openless-all/app/src-tauri/src/issue1338_debug.rs +++ /dev/null @@ -1,956 +0,0 @@ -//! [DEBUG-ISSUE-1338] A2 Windows panel-open experiment; effectiveness unconfirmed. -//! Fixed closed facts only. Producers never serialize, write, initialize a sink, -//! acquire application locks, or wait for queue capacity. try_send is not lock-free. -//! Missing records and the final flush tail remain unknown, including forced exit. -//! Backend-only: no frontend intent/send/response-application coverage. A null -//! parent is unknown; no IDs are inferred from command names or thread proximity. -//! P4 Requested begins the wrapper (possibly before handle acquisition); only -//! DispatchReturned reports dispatch acceptance, never execution completion. -//! P5 covers exported native checks, not every internal platform helper. -#![allow(dead_code)] -use serde::Serialize; -pub(crate) const ATTEMPT: &str = "A2-voice-edit-open-async"; -pub(crate) const CHANGE: &str = "C2-async-command-dispatch"; -pub(crate) const SESSION: &str = "issue-1338-20261106"; -pub(crate) const LOG_NAME: &str = "openless-issue-1338-20261106-session.ndjson"; -pub(crate) const fn enabled() -> bool { - cfg!(all(target_os = "windows", feature = "issue-1338-diagnostics")) -} -#[derive(Clone, Copy, Debug, Serialize)] -pub(crate) enum Probe { P1, P2, P3, P4, P5, P6 } -impl Probe { - fn hypotheses(self) -> &'static [&'static str] { - match self { - Self::P1 | Self::P2 | Self::P4 => &["R2-H1", "R2-H2", "R2-H3", "R2-H4"], - Self::P6 => &["R2-H3"], - Self::P3 | Self::P5 => &["R2-H4"], - } - } -} -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum Event { - Entered, Returned, ScopeLeft, Requested, Acquired, Released, DispatchReturned, - Ready, ExitRequested, Exit, CloseRequested, Destroyed, -} -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum Outcome { - Observed, Returned, Ok, Error, Granted, Denied, NotDetermined, Restricted, - NotApplicable, NoDevice, -} -#[derive(Clone, Copy, Debug, Default, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum Kind { - #[default] None, - GetSettings, ListChannels, ReadCredential, Accessibility, Microphone, - StartupSnapshot, PrefsRead, WarmConfigure, CredentialWorker, - CredentialsLock, CacheLock, HostDispatch, CapsuleDispatch, TrayQuit, QuitRequest, - SettingsSnapshot, UpdateSettingFields, SetSettings, QaHotkeySettings, - SelectionHotkeySettings, GetHotkeyCapability, ShortcutRecording, - SettingsHostGate, SnapshotRevision, SnapshotPreferences, SnapshotVerify, - SettingsPersist, SettingsRefresh, - SelectionHotkeyRegistration, QaHotkeyRegistration, OtherHotkeyRegistration, - VoiceEditOpen, VoiceEditLookup, VoiceEditCreate, VoiceEditFocusable, VoiceEditShow, -} -impl Kind { - // Never pass through arbitrary command names, arguments, labels or payloads. - pub(crate) fn command(command: &str) -> Option { - match command { - "voice_edit_window_open" => Some(Self::VoiceEditOpen), - "get_settings" => Some(Self::GetSettings), - "get_settings_snapshot" => Some(Self::SettingsSnapshot), - "update_setting_fields" => Some(Self::UpdateSettingFields), - "set_settings" => Some(Self::SetSettings), - "set_qa_hotkey" => Some(Self::QaHotkeySettings), - "set_selection_polish_hotkey" => Some(Self::SelectionHotkeySettings), - "get_hotkey_capability" => Some(Self::GetHotkeyCapability), - "set_shortcut_recording_active" => Some(Self::ShortcutRecording), - "get_startup_snapshot" => Some(Self::StartupSnapshot), - "list_channels" => Some(Self::ListChannels), - "read_credential" => Some(Self::ReadCredential), - "check_accessibility_permission" => Some(Self::Accessibility), - "check_microphone_permission" => Some(Self::Microphone), - _ => None, - } - } -} -/// Fixed source category, never an arbitrary webview label or page identity. -#[derive(Clone, Copy, Debug, Default, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub(crate) enum WebviewKind { Main, Other, #[default] Unknown } -impl WebviewKind { - pub(crate) fn from_label(label: &str) -> Self { - if label == "main" { Self::Main } else { Self::Other } - } -} -#[derive(Clone, Copy, Debug, Default, Serialize)] -pub(crate) struct Facts { pub kind: Kind, pub value: u64, pub flag: bool, pub webview: WebviewKind } -#[derive(Clone, Copy, Debug)] -pub(crate) struct Operation { id: u64, parent: u64, probe: Probe } -impl Operation { - pub(crate) fn new(probe: Probe) -> Self { Self::child(probe, current()) } - pub(crate) fn child(probe: Probe, parent: Option) -> Self { - #[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] - let id = sink::NEXT_OPERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - #[cfg(not(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics"))))] - let id = 0; - Self { id, parent: parent.map_or(0, |p| p.id), probe } - } - pub(crate) fn record(self, event: Event, outcome: Outcome, facts: Facts) { - #[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] - sink::record(self, event, outcome, facts); - #[cfg(not(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics"))))] - let _ = (event, outcome, facts); - } - pub(crate) fn mark(self, event: Event) { self.record(event, Outcome::Observed, Facts::default()); } -} -#[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] -thread_local! { - static CURRENT: std::cell::Cell> = const { std::cell::Cell::new(None) }; - static THREAD: u64 = NEXT_THREAD.fetch_add(1, std::sync::atomic::Ordering::Relaxed); -} -#[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] -static NEXT_THREAD: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); -fn current() -> Option { - #[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] - { CURRENT.with(std::cell::Cell::get) } - #[cfg(not(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics"))))] - { None } -} -#[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] -fn thread_label() -> u64 { THREAD.with(|label| *label) } -/// Only install inside a real synchronous stack; !Send, never held across await. -pub(crate) struct SyncContext { - previous: Option, - _not_send: std::marker::PhantomData>, -} -impl SyncContext { - pub(crate) fn enter(operation: Operation) -> Self { - #[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] - let previous = CURRENT.with(|slot| slot.replace(Some(operation))); - #[cfg(not(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics"))))] - let previous = None; - Self { previous, _not_send: std::marker::PhantomData } - } -} -impl Drop for SyncContext { - fn drop(&mut self) { - #[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] - CURRENT.with(|slot| slot.set(self.previous)); - } -} -/// Owns no TLS context. Safe to move with an async future; cancellation is scope_left. -pub(crate) struct Boundary { pub operation: Operation, pub kind: Kind } -impl Boundary { - pub(crate) fn enter(probe: Probe, kind: Kind) -> Self { - let boundary = Self { operation: Operation::new(probe), kind }; - boundary.record(Event::Entered, Outcome::Observed); boundary - } - pub(crate) fn record(&self, event: Event, outcome: Outcome) { - self.operation.record(event, outcome, Facts { kind: self.kind, ..Default::default() }); - } - pub(crate) fn returned(&self) { self.record(Event::Returned, Outcome::Returned); } -} -impl Drop for Boundary { fn drop(&mut self) { self.record(Event::ScopeLeft, Outcome::Observed); } } -/// An async command's operation has no invented dispatch parent. Only its -/// individual poll stack is a real synchronous parent for services invoked there. -/// This wrapper pins in place and restores TLS before every Pending/Ready/unwind. -pub(crate) async fn observe_future(kind: Kind, future: impl std::future::Future) -> T { - let boundary = Boundary::enter(Probe::P2, kind); - let mut future = std::pin::pin!(future); - let result = std::future::poll_fn(|cx| { - let _context = SyncContext::enter(boundary.operation); - std::future::Future::poll(future.as_mut(), cx) - }).await; - boundary.returned(); - result -} - -#[derive(Clone, Copy)] -pub(crate) struct MainDispatch { operation: Operation, kind: Kind } -impl MainDispatch { - pub(crate) fn new(kind: Kind) -> Self { - let this = Self { operation: Operation::new(Probe::P4), kind }; - this.record(Event::Requested, Outcome::Observed); this - } - fn record(self, event: Event, outcome: Outcome) { - self.operation.record(event, outcome, Facts { kind: self.kind, ..Default::default() }); - } - pub(crate) fn wrap(self, task: impl FnOnce() + Send + 'static) -> impl FnOnce() + Send + 'static { - move || { - let _context = SyncContext::enter(self.operation); - let boundary = Boundary { operation: self.operation, kind: self.kind }; - boundary.record(Event::Entered, Outcome::Observed); - task(); boundary.returned(); - } - } - // Acceptance/inline return only, NOT closure execution or async completion. - pub(crate) fn dispatched(self, accepted: bool) { - self.record(Event::DispatchReturned, if accepted { Outcome::Ok } else { Outcome::Error }); - } -} -#[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] -pub(crate) struct Guard<'a, T: ?Sized> { - guard: Option>, boundary: Boundary, - // Only the synchronous host-gate scope installs context for its real - // nested main-thread dispatches. MutexGuard and SyncContext are !Send. - _context: Option, -} -#[cfg(not(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics"))))] -pub(crate) type Guard<'a, T> = parking_lot::MutexGuard<'a, T>; -pub(crate) fn lock(mutex: &parking_lot::Mutex, kind: Kind) -> Guard<'_, T> { - lock_at(mutex, Probe::P3, kind) -} -pub(crate) fn lock_at(mutex: &parking_lot::Mutex, probe: Probe, kind: Kind) -> Guard<'_, T> { - #[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] - { - let boundary = Boundary { operation: Operation::new(probe), kind }; - boundary.record(Event::Requested, Outcome::Observed); - let guard = mutex.lock(); - boundary.record(Event::Acquired, Outcome::Observed); - let context = matches!(kind, Kind::SettingsHostGate | Kind::SelectionHotkeyRegistration - | Kind::QaHotkeyRegistration | Kind::OtherHotkeyRegistration) - .then(|| SyncContext::enter(boundary.operation)); - Guard { guard: Some(guard), boundary, _context: context } - } - #[cfg(not(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics"))))] - { let _ = (probe, kind); mutex.lock() } -} -#[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] -impl std::ops::Deref for Guard<'_, T> { - type Target = T; - fn deref(&self) -> &T { self.guard.as_deref().expect("guard alive") } -} -#[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] -impl std::ops::DerefMut for Guard<'_, T> { - fn deref_mut(&mut self) -> &mut T { self.guard.as_deref_mut().expect("guard alive") } -} -#[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] -impl Drop for Guard<'_, T> { - fn drop(&mut self) { - drop(self.guard.take()); // Emit after the REAL unlock, never before it. - self.boundary.record(Event::Released, Outcome::Observed); - } -} -pub(crate) fn initialize() { - #[cfg(all(target_os = "windows", feature = "issue-1338-diagnostics", not(test)))] - sink::initialize(); -} -pub(crate) fn build_id() -> &'static str { - let value = option_env!("OPENLESS_BUILD_ID").unwrap_or("unbound"); - if value.len() == 40 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) { value } - else { "unbound" } -} - -#[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] -#[path = "issue1338_export.rs"] -mod export; -#[cfg(all(target_os = "windows", feature = "issue-1338-diagnostics"))] -pub(crate) fn export_log(source: &std::path::Path, target: &std::path::Path) -> Result<(), String> { - export::export_log(source, target).map_err(str::to_owned) -} - -#[derive(Default, Serialize)] -pub(crate) struct Health { - enabled: bool, - build_id: &'static str, - run_id: Option, - initialized: bool, - ready: bool, - dropped: u64, - queue_closed: u64, - spawn_failed: bool, - io_failed: bool, - written: u64, - flushed: u64, -} - -/// Independent counters, not a record through the possibly failed channel. -/// A snapshot is not transactional; flush is an OS write, not durable fsync. -pub(crate) fn health() -> Health { - #[allow(unused_mut)] - let mut health = Health { enabled: enabled(), build_id: build_id(), ..Health::default() }; - #[cfg(all(target_os = "windows", feature = "issue-1338-diagnostics"))] - if let Some(sink) = sink::SINK.get() { - sink.health.snapshot(&mut health); - health.run_id = Some(format!("r{:x}", sink.run)); - } - health -} - -#[cfg(any(test, all(target_os = "windows", feature = "issue-1338-diagnostics")))] -mod sink { - use super::*; - use std::io::{self, Write}; - use std::sync::atomic::{AtomicBool, AtomicU64, Ordering::Relaxed}; - use std::sync::mpsc::{sync_channel, Receiver, SyncSender, TrySendError}; - use std::sync::{Arc, OnceLock}; - use std::time::{Instant, SystemTime, UNIX_EPOCH}; - - pub(super) static NEXT_OPERATION: AtomicU64 = AtomicU64::new(1); - // One source sequence per probe, not a global clock or cross-probe causality. - static SEQUENCES: [AtomicU64; 6] = [const { AtomicU64::new(0) }; 6]; - pub(super) static SINK: OnceLock = OnceLock::new(); - - #[derive(Default)] - pub(super) struct HealthState { - initialized: AtomicBool, - ready: AtomicBool, - dropped: AtomicU64, - queue_closed: AtomicU64, - spawn_failed: AtomicBool, - io_failed: AtomicBool, - written: AtomicU64, - flushed: AtomicU64, - } - impl HealthState { - fn snapshot_value(&self) -> Health { - let mut health = Health { enabled: enabled(), build_id: build_id(), ..Default::default() }; - self.snapshot(&mut health); - health - } - pub(super) fn snapshot(&self, health: &mut Health) { - health.initialized = self.initialized.load(Relaxed); - health.ready = self.ready.load(Relaxed); - health.dropped = self.dropped.load(Relaxed); - health.queue_closed = self.queue_closed.load(Relaxed); - health.spawn_failed = self.spawn_failed.load(Relaxed); - health.io_failed = self.io_failed.load(Relaxed); - health.written = self.written.load(Relaxed); - health.flushed = self.flushed.load(Relaxed); - } - } - - #[derive(Clone, Copy, Debug)] - struct Record { - operation: Operation, - event: Event, - outcome: Outcome, - facts: Facts, - seq: u64, - elapsed_us: u64, - thread_label: u64, - } - - #[derive(Debug)] - enum Message { - Record(Record), - Snapshot(SyncSender>), - } - pub(super) struct Sink { - sender: SyncSender, - pub(super) health: Arc, - started: Instant, - pub(super) run: u128, - } - - #[cfg(test)] - thread_local! { - static INSPECT: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; - } - impl Sink { - fn submit(&self, record: Record) { - #[cfg(test)] - INSPECT.with(|hook| { if let Some(inspect) = hook.borrow().as_ref() { inspect(&record); } }); - match self.sender.try_send(Message::Record(record)) { - Ok(()) => {}, - Err(TrySendError::Full(_)) => { self.health.dropped.fetch_add(1, Relaxed); }, - Err(TrySendError::Disconnected(_)) => { - self.health.dropped.fetch_add(1, Relaxed); - self.health.queue_closed.fetch_add(1, Relaxed); - }, - } - } - } - - fn next_sequence(sequences: &[AtomicU64; 6], probe: Probe) -> u64 { - sequences[probe as usize].fetch_add(1, Relaxed) - } - - fn submit(sink: &Sink, operation: Operation, event: Event, outcome: Outcome, facts: Facts) { - sink.submit(Record { - operation, event, outcome, facts, - seq: next_sequence(&SEQUENCES, operation.probe), - elapsed_us: sink.started.elapsed().as_micros().min(u64::MAX as u128) as u64, - thread_label: thread_label(), - }); - } - - #[cfg(test)] - thread_local! { - static TEST_SINK: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; - } - - pub(super) fn record(operation: Operation, event: Event, outcome: Outcome, facts: Facts) { - // No lazy spawn, channel allocation or file initialization in a probe. - #[cfg(test)] - if TEST_SINK.with(|slot| { - if let Some(sink) = slot.borrow().as_ref() { - submit(sink, operation, event, outcome, facts); - true - } else { false } - }) { return; } - if let Some(sink) = SINK.get() { submit(sink, operation, event, outcome, facts); } - } - - #[cfg(test)] - pub(super) fn capture(action: impl FnOnce()) -> Vec { - let (sender, receiver) = sync_channel(512); - let health = Arc::new(HealthState::default()); - let sink = Sink { sender, health: Arc::clone(&health), started: Instant::now(), run: 1 }; - TEST_SINK.with(|slot| assert!(slot.borrow_mut().replace(sink).is_none())); - struct Reset; - impl Drop for Reset { - fn drop(&mut self) { TEST_SINK.with(|slot| { slot.borrow_mut().take(); }); } - } - let reset = Reset; - action(); - drop(reset); - let mut bytes = Vec::new(); - drain(&mut bytes, receiver, 1, 0, &health); - assert_eq!(health.dropped.load(Relaxed), 0); - bytes.split(|byte| *byte == b'\n').filter(|line| !line.is_empty()) - .map(|line| serde_json::from_slice(line).unwrap()).collect() - } - - fn encode(record: Record, run: u128, started_ms: u128, health: &HealthState) -> io::Result> { - let operation = record.operation; - let mut line = serde_json::to_vec(&serde_json::json!({ - "schema": "diagnosing-bugs.v1", "session_id": SESSION, - "run_id": format!("r{run:x}"), "build_id": build_id(), - "attempt_id": ATTEMPT, "change_id": CHANGE, - "probe_id": operation.probe, "hypothesis_ids": operation.probe.hypotheses(), - "operation_id": operation.id, "parent_operation_id": (operation.parent != 0).then_some(operation.parent), - "parent_capture": if operation.parent == 0 { "unknown" } else { "source_local" }, - "thread_label": record.thread_label, - "event_seq": record.seq, "event": record.event, "outcome": record.outcome, - "safe": record.facts, "monotonic_us": record.elapsed_us, - "capture_health": health.snapshot_value(), - "frontend_coverage": { - "scope": "backend_only", - "request_intent": "unknown", - "request_send": "unknown", - "response_application": "unknown", - "transport_loss": "unknown" - }, - "tail_capture": "unknown", "flush_durability": "os_not_fsync", - "ts": (started_ms + u128::from(record.elapsed_us / 1000)).to_string(), - }))?; - line.push(b'\n'); - Ok(line) - } - - // Only exports wait, on a blocking worker, with a finite failure boundary. - // Probe producers retain bounded try_send and never wait for this handshake. - pub(super) fn snapshot() -> Result { - request_snapshot(SINK.get().ok_or("issue1338_no_writer: capture unknown")?) - } - fn request_snapshot(sink: &Sink) -> Result { - let (tx, rx) = sync_channel(1); - sink.sender.try_send(Message::Snapshot(tx)) - .map_err(|_| "issue1338_checkpoint_unavailable: capture unknown")?; - rx.recv_timeout(std::time::Duration::from_secs(3)) - .map_err(|_| "issue1338_checkpoint_unacknowledged: capture unknown")? - } - - fn checkpoint(writer: &mut impl Write, run: u128, offset: u64, - health: &HealthState) -> io::Result { - let mut state = health.snapshot_value(); - state.run_id = Some(format!("r{run:x}")); - let incomplete = state.dropped != 0 || state.queue_closed != 0 || state.io_failed || state.spawn_failed; - let mut line = serde_json::to_vec(&serde_json::json!({ - "schema": "issue1338-export-checkpoint.v1", "session_id": SESSION, - "attempt_id": ATTEMPT, "change_id": CHANGE, - "checkpoint_start_byte": offset, - "capture_unix_ms": SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_millis()), - "run_id": state.run_id, "build_id": build_id(), "feature_enabled": enabled(), - "capture_health": state, - "counter_scope": "current_backend_run_before_checkpoint", - "record_counters": "probe_records_only_excludes_export_checkpoints", - "backend_preinitialization_capture": "unknown", - "checkpoint_commit": "ack_only_after_write_and_flush", - "historical_runs": "included_not_current_coverage_unknown", - "frontend_coverage": { "scope": "backend_only", "request_intent": "unknown", - "request_send": "unknown", "response_application": "unknown", "transport_loss": "unknown" }, - "tail_capture": "unknown", "flush_durability": "os_not_fsync", - "coverage": if incomplete { "incomplete" } else { "backend_checkpoint_only_not_complete_capture" } - }))?; - line.push(b'\n'); - writer.write_all(&line)?; - writer.flush()?; - let end = offset.checked_add(line.len() as u64).ok_or_else(|| io::Error::other("offset"))?; - Ok(super::export::Snapshot { end, checkpoint: line, incomplete }) - } - - fn drain(writer: impl Write, rx: Receiver, run: u128, start: u128, - health: &HealthState) { - drain_at(writer, rx, run, start, health, 0); - } - fn drain_at(mut writer: impl Write, rx: Receiver, run: u128, start: u128, - health: &HealthState, mut offset: u64) { - health.ready.store(true, Relaxed); - for message in rx { - let result = match message { - Message::Record(record) => (|| -> io::Result<()> { - let line = encode(record, run, start, health)?; - writer.write_all(&line)?; - health.written.fetch_add(1, Relaxed); - writer.flush()?; - health.flushed.fetch_add(1, Relaxed); - offset = offset.checked_add(line.len() as u64).ok_or_else(|| io::Error::other("offset"))?; - Ok(()) - })(), - Message::Snapshot(reply) => match checkpoint(&mut writer, run, offset, health) { - Ok(snapshot) => { - offset = snapshot.end; - let _ = reply.try_send(Ok(snapshot)); - Ok(()) - }, - Err(error) => { - let _ = reply.try_send(Err("issue1338_checkpoint_io_failed: capture unknown")); - Err(error) - } - }, - }; - if result.is_err() { health.io_failed.store(true, Relaxed); break; } - } - health.ready.store(false, Relaxed); - } - - #[cfg(test)] - pub(super) fn export_fixture() -> (Vec, super::export::Snapshot) { - let (tx, rx) = sync_channel(2); - let (reply, ack) = sync_channel(1); - tx.send(Message::Record(Record { - operation: Operation { id: 1, parent: 0, probe: Probe::P2 }, - event: Event::Entered, outcome: Outcome::Observed, facts: Facts::default(), - seq: 0, elapsed_us: 0, thread_label: 1, - })).unwrap(); - tx.send(Message::Snapshot(reply)).unwrap(); - drop(tx); - let health = HealthState::default(); - health.initialized.store(true, Relaxed); - let mut bytes = Vec::new(); - drain(&mut bytes, rx, 0x1338, 0, &health); - (bytes, ack.try_recv().unwrap().unwrap()) - } - - #[cfg(all(target_os = "windows", feature = "issue-1338-diagnostics", not(test)))] - pub(super) fn initialize() { - SINK.get_or_init(|| { - let (sender, receiver) = sync_channel(512); - let health = Arc::new(HealthState::default()); - health.initialized.store(true, Relaxed); - let writer_health = Arc::clone(&health); - let started = Instant::now(); - let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default(); - if std::thread::Builder::new().name("issue-1338-writer".into()).spawn(move || { - // Only the writer touches the session-owned append-only file. - let file = (|| -> io::Result { - let base = std::env::var_os("LOCALAPPDATA") - .filter(|value| !value.is_empty()) - .ok_or_else(|| io::Error::other("unavailable"))?; - let directory = std::path::PathBuf::from(base).join("OpenLess").join("diagnostics"); - std::fs::create_dir_all(&directory)?; - std::fs::OpenOptions::new().create(true).append(true).open(directory.join(LOG_NAME)) - })(); - match file { - Ok(file) => match file.metadata() { - Ok(meta) => drain_at(file, receiver, now.as_nanos(), now.as_millis(), &writer_health, meta.len()), - Err(_) => { writer_health.io_failed.store(true, Relaxed); }, - }, - Err(_) => { writer_health.io_failed.store(true, Relaxed); }, - } - }).is_err() { health.spawn_failed.store(true, Relaxed); } - Sink { sender, health, started, run: now.as_nanos() } - }); - } - - #[cfg(test)] - mod tests { - use super::*; - #[test] - fn generic_guard_unlocks_before_emitting_release_and_keeps_temporary_lifetime() { - let mutex = Arc::new(parking_lot::Mutex::new(Some(3_u64))); - let check = Arc::clone(&mutex); - INSPECT.with(|hook| *hook.borrow_mut() = Some(Box::new(move |record| { - if matches!(record.event, Event::Released) { assert!(check.try_lock().is_some()); } - }))); - let records = capture(|| { - if let Some(value) = lock(&mutex, Kind::CacheLock).as_mut() { - *value = 4; - assert!(mutex.try_lock().is_none()); - } - assert_eq!(*mutex.lock(), Some(4)); - }); - INSPECT.with(|hook| { hook.borrow_mut().take(); }); - let events: Vec<_> = records.iter().map(|r| r["event"].as_str().unwrap()).collect(); - assert_eq!(events, ["requested", "acquired", "released", "scope_left"]); - } - #[test] - fn exact_production_capacity_and_loss_snapshot_are_explicit() { - let (sender, receiver) = sync_channel(512); - let sink = Sink { sender, health: Arc::default(), started: Instant::now(), run: 1 }; - for _ in 0..513 { sink.submit(sample()); } - assert_eq!(sink.health.dropped.load(Relaxed), 1); - let bytes = encode(sample(), 1, 0, &sink.health).unwrap(); - let record: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(record["capture_health"]["dropped"], 1); - assert_eq!(record["tail_capture"], "unknown"); - assert_eq!(record["flush_durability"], "os_not_fsync"); - drop(receiver); - } - #[test] - fn zero_backend_loss_does_not_claim_frontend_evidence_coverage() { - let bytes = encode(sample(), 1, 0, &HealthState::default()).unwrap(); - let record: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(record["capture_health"]["dropped"], 0); - assert_eq!(record["frontend_coverage"], serde_json::json!({ - "scope": "backend_only", - "request_intent": "unknown", - "request_send": "unknown", - "response_application": "unknown", - "transport_loss": "unknown" - })); - } - fn sample() -> Record { - Record { operation: Operation { id: 2, parent: 1, probe: Probe::P5 }, - event: Event::ScopeLeft, outcome: Outcome::Observed, - facts: Facts { kind: Kind::Microphone, value: 0, flag: false, ..Default::default() }, seq: 4, elapsed_us: 1234, thread_label: 7 } - } - #[test] - fn schema_is_typed_single_line_and_scope_left_is_not_success() { - let line = encode(sample(), 123, 456, &HealthState::default()).unwrap(); - assert_eq!(line.iter().filter(|byte| **byte == b'\n').count(), 1); - let value: serde_json::Value = serde_json::from_slice(&line).unwrap(); - for key in ["schema", "session_id", "run_id", "probe_id", "hypothesis_ids", "event", "outcome", "safe", "ts", "build_id", "capture_health", "tail_capture"] { - assert!(value.get(key).is_some(), "missing closed schema field"); - } - assert_eq!(value["session_id"], SESSION); - assert_eq!(value["attempt_id"], ATTEMPT); - assert_eq!(value["change_id"], CHANGE); - assert_eq!(value["schema"], "diagnosing-bugs.v1"); - assert_eq!(value["probe_id"], "P5"); - assert_eq!(value["hypothesis_ids"], serde_json::json!(["R2-H4"])); - assert_eq!(value["event"], "scope_left"); - assert_eq!(value["outcome"], "observed"); - assert_eq!(value["parent_operation_id"], 1); - assert_eq!(value["event_seq"], 4); - assert_eq!(value["thread_label"], 7); - let safe = value["safe"].as_object().unwrap(); - assert_eq!(safe, serde_json::json!({ - "flag": false, "kind": "microphone", "value": 0, "webview": "unknown" - }).as_object().unwrap()); - } - #[test] - fn full_and_disconnected_are_counted_without_waiting() { - let (sender, receiver) = sync_channel(1); - let sink = Sink { sender, health: Arc::default(), started: Instant::now(), run: 1 }; - sink.submit(sample()); - sink.submit(sample()); - assert_eq!(sink.health.dropped.load(Relaxed), 1); - assert_eq!(sink.health.queue_closed.load(Relaxed), 0); - receiver.try_recv().unwrap(); - drop(receiver); - sink.submit(sample()); - assert_eq!(sink.health.dropped.load(Relaxed), 2); - assert_eq!(sink.health.queue_closed.load(Relaxed), 1); - } - struct FailWrite; - impl Write for FailWrite { - fn write(&mut self, _: &[u8]) -> io::Result { Err(io::Error::other("injected")) } - fn flush(&mut self) -> io::Result<()> { panic!("must not flush after a failed write") } - } - #[test] - fn write_failure_does_not_claim_a_written_or_flushed_record() { - let (tx, rx) = sync_channel(1); - tx.send(Message::Record(sample())).unwrap(); drop(tx); - let health = HealthState::default(); - drain(FailWrite, rx, 1, 2, &health); - assert!(health.io_failed.load(Relaxed)); - assert!(!health.ready.load(Relaxed)); - assert_eq!(health.written.load(Relaxed), 0); - assert_eq!(health.flushed.load(Relaxed), 0); - } - struct FailFlush(Vec); - impl Write for FailFlush { - fn write(&mut self, bytes: &[u8]) -> io::Result { self.0.write(bytes) } - fn flush(&mut self) -> io::Result<()> { Err(io::Error::other("injected")) } - } - #[test] - fn io_failure_retains_independent_write_and_flush_watermarks() { - let (tx, rx) = sync_channel(1); - tx.send(Message::Record(sample())).unwrap(); drop(tx); - let health = HealthState::default(); - drain(FailFlush(Vec::new()), rx, 1, 2, &health); - assert!(health.io_failed.load(Relaxed)); - assert!(!health.ready.load(Relaxed)); - assert_eq!(health.written.load(Relaxed), 1); - assert_eq!(health.flushed.load(Relaxed), 0); - } - #[test] - fn successful_writer_records_every_complete_line() { - let (tx, rx) = sync_channel(2); - tx.send(Message::Record(sample())).unwrap(); tx.send(Message::Record(sample())).unwrap(); drop(tx); - let health = HealthState::default(); - let mut bytes = Vec::new(); - drain(&mut bytes, rx, 1, 2, &health); - assert_eq!(health.flushed.load(Relaxed), 2); - assert_eq!(bytes.split(|b| *b == b'\n').filter(|line| !line.is_empty()).count(), 2); - } - #[test] - fn current_checkpoint_is_serialized_by_writer_and_acknowledged_only_after_flush() { - let (tx, rx) = sync_channel(2); - let (reply, ack) = sync_channel(1); - tx.send(Message::Record(sample())).unwrap(); - tx.send(Message::Snapshot(reply)).unwrap(); - drop(tx); - let health = HealthState::default(); - health.initialized.store(true, Relaxed); - let mut bytes = b"historical\n".to_vec(); - let initial = bytes.len() as u64; - drain_at(&mut bytes, rx, 0xab, 0, &health, initial); - let snapshot = ack.try_recv().unwrap().unwrap(); - assert_eq!(snapshot.end, bytes.len() as u64); - assert!(bytes.ends_with(&snapshot.checkpoint)); - assert!(!snapshot.incomplete); - let value: serde_json::Value = serde_json::from_slice(&snapshot.checkpoint).unwrap(); - assert_eq!(value["run_id"], "rab"); - assert_eq!(value["capture_health"]["run_id"], "rab"); - assert_eq!(value["capture_health"]["written"], 1); - assert_eq!(value["capture_health"]["flushed"], 1); - assert_eq!(value["capture_health"]["dropped"], 0); - assert_eq!(value["capture_health"]["ready"], true); - assert_eq!(value["feature_enabled"], enabled()); - assert_eq!(value["build_id"], build_id()); - assert_eq!(value["tail_capture"], "unknown"); - assert_eq!(value["frontend_coverage"], serde_json::json!({ - "scope": "backend_only", "request_intent": "unknown", "request_send": "unknown", - "response_application": "unknown", "transport_loss": "unknown" - })); - assert_eq!(value["historical_runs"], "included_not_current_coverage_unknown"); - assert_eq!(value["backend_preinitialization_capture"], "unknown"); - } - #[test] - fn checkpoint_write_and_flush_failure_never_acknowledge_an_old_snapshot() { - fn run(writer: impl Write) { - let (tx, rx) = sync_channel(1); - let (reply, ack) = sync_channel(1); - tx.send(Message::Snapshot(reply)).unwrap(); - drop(tx); - let health = HealthState::default(); - drain(writer, rx, 2, 0, &health); - assert!(ack.try_recv().unwrap().is_err()); - assert!(health.io_failed.load(Relaxed)); - } - run(FailWrite); - run(FailFlush(b"old snapshot\n".to_vec())); - } - #[test] - fn record_failure_before_checkpoint_cannot_authorize_readable_old_file() { - let (tx, rx) = sync_channel(2); - let (reply, ack) = sync_channel(1); - tx.send(Message::Record(sample())).unwrap(); - tx.send(Message::Snapshot(reply)).unwrap(); - drop(tx); - let health = HealthState::default(); - drain(FailFlush(b"old snapshot\n".to_vec()), rx, 2, 0, &health); - assert!(ack.try_recv().is_err()); - assert!(health.io_failed.load(Relaxed)); - } - #[test] - fn unavailable_writer_and_full_or_closed_queue_are_explicit_failures() { - assert!(snapshot().is_err()); // initialize() under tests never initializes SINK. - let (sender, receiver) = sync_channel(1); - let sink = Sink { sender, health: Arc::default(), started: Instant::now(), run: 9 }; - sink.submit(sample()); - assert!(request_snapshot(&sink).is_err()); // no waiting on full queue - drop(receiver); - assert!(request_snapshot(&sink).is_err()); - } - #[test] - fn writer_checkpoint_preserves_nonzero_loss_instead_of_claiming_complete() { - let health = HealthState::default(); - health.dropped.store(1, Relaxed); - let snapshot = checkpoint(&mut Vec::new(), 7, 0, &health).unwrap(); - assert!(snapshot.incomplete); - let value: serde_json::Value = serde_json::from_slice(&snapshot.checkpoint).unwrap(); - assert_eq!(value["coverage"], "incomplete"); - assert_eq!(value["capture_health"]["dropped"], 1); - } - #[test] - fn probe_sources_have_independent_sequences() { - // Use the real producer helper with private counters: other tests - // deliberately submit records concurrently through global SEQUENCES. - let sequences = [const { AtomicU64::new(0) }; 6]; - let first = next_sequence(&sequences, Probe::P1); - assert_eq!(first, 0); - assert_eq!(next_sequence(&sequences, Probe::P2), 0); - assert_eq!(next_sequence(&sequences, Probe::P1), first + 1); - assert_eq!(next_sequence(&sequences, Probe::P2), 1); - assert!(sequences[2..].iter().all(|sequence| sequence.load(Relaxed) == 0)); - } - } -} - -#[cfg(test)] -mod boundary_tests { - use super::*; - use std::future::Future; - use std::task::{Context, Poll, Waker}; - #[test] - fn disabled_facade_and_test_initialize_never_open_real_files() { - initialize(); - assert!(sink::SINK.get().is_none()); - if !enabled() { - assert!(!health().initialized); - assert!(!health().ready); - assert!(health().run_id.is_none()); - } - assert!(build_id() == "unbound" || (build_id().len() == 40 && build_id().bytes().all(|b| b.is_ascii_hexdigit()))); - } - #[test] - fn command_allowlist_redacts_unknown_names_and_startup_is_covered() { - for name in ["get_settings", "list_channels", "read_credential", "get_startup_snapshot", "check_accessibility_permission", "check_microphone_permission", "get_settings_snapshot", "update_setting_fields", "set_settings", "set_qa_hotkey", "set_selection_polish_hotkey", "get_hotkey_capability", "set_shortcut_recording_active"] { - assert!(Kind::command(name).is_some()); - } - for secret in ["", "personal-window", "secret-token", "get_settings extra-args", "plugin:untrusted|value"] { - assert!(Kind::command(secret).is_none()); - } - assert_eq!(Probe::P2.hypotheses(), ["R2-H1", "R2-H2", "R2-H3", "R2-H4"]); - assert!(matches!(Kind::command("voice_edit_window_open"), Some(Kind::VoiceEditOpen))); - } - #[test] - fn webview_origin_is_a_closed_source_category_not_a_window_identity() { - assert_eq!(WebviewKind::from_label("main"), WebviewKind::Main); - assert_eq!(WebviewKind::from_label("private-window-name"), WebviewKind::Other); - let records = sink::capture(|| { - Operation::new(Probe::P1).record(Event::Entered, Outcome::Observed, - Facts { kind: Kind::SettingsSnapshot, - webview: WebviewKind::from_label("private-window-name"), ..Default::default() }); - }); - assert_eq!(records[0]["safe"]["webview"], "other"); - assert!(!records[0].to_string().contains("private-window-name")); - } - #[test] - fn settings_gate_is_the_real_parent_only_while_its_sync_guard_is_alive() { - let records = sink::capture(|| { - let mutex = parking_lot::Mutex::new(()); - let gate = lock_at(&mutex, Probe::P2, Kind::SettingsHostGate); - let dispatch = MainDispatch::new(Kind::HostDispatch); - dispatch.wrap(|| {})(); - dispatch.dispatched(true); - drop(gate); - assert!(current().is_none()); - assert!(mutex.try_lock().is_some()); - }); - let gate = records.iter().find(|r| r["probe_id"] == "P2" && r["event"] == "requested").unwrap(); - let dispatch = records.iter().find(|r| r["probe_id"] == "P4" && r["event"] == "requested").unwrap(); - assert_eq!(dispatch["parent_operation_id"], gate["operation_id"]); - assert!(records.iter().any(|r| r["probe_id"] == "P2" && r["event"] == "released")); - } - #[test] - fn cancellation_is_scope_left_and_poll_context_never_leaks_across_await() { - let records = sink::capture(|| { - let outer = Operation::new(Probe::P6); - let _outer = SyncContext::enter(outer); - let mut future = Box::pin(observe_future(Kind::ListChannels, async { - assert_ne!(current().unwrap().id, outer.id); - std::future::pending::<()>().await; - })); - fn require_send(_: &T) {} - require_send(&future); - let mut cx = Context::from_waker(Waker::noop()); - assert!(matches!(future.as_mut().poll(&mut cx), Poll::Pending)); - assert_eq!(current().unwrap().id, outer.id); - drop(future); - assert_eq!(current().unwrap().id, outer.id); - }); - let events: Vec<_> = records.iter().map(|r| r["event"].as_str().unwrap()).collect(); - assert_eq!(events, ["entered", "scope_left"]); - assert!(records.iter().all(|r| r["outcome"] == "observed")); - } - #[test] - fn successful_future_returns_identical_result_and_real_sync_children() { - let records = sink::capture(|| { - let mut future = Box::pin(observe_future(Kind::ReadCredential, async { - let parent = current().unwrap(); - let worker = Operation::new(Probe::P2); - assert_eq!(worker.parent, parent.id); - Err::<(), u64>(7) - })); - let mut cx = Context::from_waker(Waker::noop()); - assert_eq!(future.as_mut().poll(&mut cx), Poll::Ready(Err(7))); - assert!(current().is_none()); - }); - assert_eq!(records[1]["event"], "returned"); - assert_eq!(records[1]["outcome"], "returned"); // Not business success. - } - #[test] - fn dispatch_acceptance_is_not_execution_and_nested_parent_is_source_local() { - let records = sink::capture(|| { - let dispatch = MainDispatch::new(Kind::HostDispatch); - let queued = dispatch.wrap(|| { - let child = MainDispatch::new(Kind::CapsuleDispatch); - child.wrap(|| {})(); - child.dispatched(true); - }); - dispatch.dispatched(true); - queued(); - }); - assert_eq!(records[0]["event"], "requested"); - assert_eq!(records[1]["event"], "dispatch_returned"); - assert_eq!(records[2]["event"], "entered"); - assert_eq!(records[3]["parent_operation_id"], records[0]["operation_id"]); - assert!(current().is_none()); - } - #[test] - fn unwinding_restores_context_and_never_reports_returned() { - let records = sink::capture(|| { - let result = std::panic::catch_unwind(|| { - MainDispatch::new(Kind::HostDispatch).wrap(|| panic!("injected"))(); - }); - assert!(result.is_err()); - assert!(current().is_none()); - }); - assert!(!records.iter().any(|r| r["event"] == "returned")); - assert_eq!(records.last().unwrap()["event"], "scope_left"); - } - - #[test] - fn inline_and_rejected_dispatch_do_not_invent_queued_execution() { - let records = sink::capture(|| { - let inline = MainDispatch::new(Kind::HostDispatch); - inline.wrap(|| {})(); - inline.dispatched(true); - let rejected = MainDispatch::new(Kind::CapsuleDispatch); - let task = rejected.wrap(|| panic!("not executed")); - drop(task); - rejected.dispatched(false); - }); - let events: Vec<_> = records.iter().map(|r| r["event"].as_str().unwrap()).collect(); - assert_eq!(events, ["requested", "entered", "returned", "scope_left", "dispatch_returned", "requested", "dispatch_returned"]); - assert_eq!(records.last().unwrap()["outcome"], "error"); - } - #[test] - fn movable_future_restores_tls_on_each_worker_poll() { - use std::sync::{Arc, atomic::{AtomicU64, Ordering::Relaxed}}; - let polls = Arc::new(AtomicU64::new(0)); - let counter = Arc::clone(&polls); - let mut future = Box::pin(observe_future(Kind::StartupSnapshot, std::future::poll_fn(move |_| { - assert!(current().is_some()); - if counter.fetch_add(1, Relaxed) == 0 { Poll::Pending } else { Poll::Ready(9_u64) } - }))); - let mut cx = Context::from_waker(Waker::noop()); - assert_eq!(future.as_mut().poll(&mut cx), Poll::Pending); - assert!(current().is_none()); - std::thread::spawn(move || { - assert!(current().is_none()); - let mut cx = Context::from_waker(Waker::noop()); - assert_eq!(future.as_mut().poll(&mut cx), Poll::Ready(9)); - assert!(current().is_none()); - }).join().unwrap(); - assert_eq!(polls.load(Relaxed), 2); - } -} diff --git a/openless-all/app/src-tauri/src/issue1338_export.rs b/openless-all/app/src-tauri/src/issue1338_export.rs deleted file mode 100644 index d0a2ac61c..000000000 --- a/openless-all/app/src-tauri/src/issue1338_export.rs +++ /dev/null @@ -1,213 +0,0 @@ -//! Single-file diagnostic delivery. Never opens the live session in unit tests. -use super::{sink, LOG_NAME}; -use std::fs::File; -use std::io::{Read, Seek, SeekFrom, Write}; -use std::path::{Path, PathBuf}; - -type ExportResult = Result; -#[derive(Debug)] -pub(super) struct Snapshot { - pub(super) end: u64, - pub(super) checkpoint: Vec, - pub(super) incomplete: bool, -} - -fn session_path() -> ExportResult { - let base = std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()) - .ok_or("issue1338_session_unavailable: capture unknown")?; - Ok(PathBuf::from(base).join("OpenLess").join("diagnostics").join(LOG_NAME)) -} - -pub(super) fn export_log(source: &Path, target: &Path) -> ExportResult<()> { - // The file's existence is not evidence that this run ever had a working sink. - // Only an acknowledgment from the current writer can authorize a snapshot. - export_with(source, &session_path()?, target, sink::snapshot) -} - -fn canonical_target(target: &Path) -> ExportResult { - if target.exists() { - return target.canonicalize().map_err(|_| "issue1338_target_unavailable: capture unknown"); - } - let parent = target.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); - let name = target.file_name().ok_or("issue1338_target_unavailable: capture unknown")?; - Ok(parent.canonicalize().map_err(|_| "issue1338_target_unavailable: capture unknown")?.join(name)) -} - -fn same_path(left: &Path, right: &Path) -> ExportResult { - #[cfg(target_os = "windows")] - { - use std::os::windows::ffi::OsStrExt; - // Canonical paths still need Windows ordinal case-insensitive comparison. - #[link(name = "kernel32")] - unsafe extern "system" { - fn CompareStringOrdinal(a: *const u16, a_len: i32, b: *const u16, b_len: i32, ignore_case: i32) -> i32; - } - let a: Vec = left.as_os_str().encode_wide().collect(); - let b: Vec = right.as_os_str().encode_wide().collect(); - let a_len = i32::try_from(a.len()).map_err(|_| "issue1338_target_unavailable: capture unknown")?; - let b_len = i32::try_from(b.len()).map_err(|_| "issue1338_target_unavailable: capture unknown")?; - // SAFETY: both buffers remain alive and the lengths are checked above. - let comparison = unsafe { CompareStringOrdinal(a.as_ptr(), a_len, b.as_ptr(), b_len, 1) }; - if comparison == 0 { return Err("issue1338_target_unavailable: capture unknown"); } - Ok(comparison == 2) // CSTR_EQUAL - } - #[cfg(not(target_os = "windows"))] - { Ok(left == right) } -} - -fn export_with(source: &Path, session: &Path, target: &Path, - capture: impl FnOnce() -> ExportResult) -> ExportResult<()> { - let source_id = source.canonicalize().map_err(|_| "issue1338_application_log_unavailable: capture unknown")?; - let session_id = session.canonicalize().map_err(|_| "issue1338_session_unavailable: capture unknown")?; - let target_id = canonical_target(target)?; - // Resolve symlinks/junctions before comparison. No source is ever truncated, - // even if a different target name is a hardlink to it: persist replaces its entry. - if same_path(&target_id, &source_id)? || same_path(&target_id, &session_id)? { - return Err("issue1338_source_target_alias: capture unknown"); - } - let snapshot = capture()?; - if snapshot.incomplete { return Err("issue1338_capture_incomplete: capture unknown"); } - let mut diagnostics = File::open(session).map_err(|_| "issue1338_session_unavailable: capture unknown")?; - let checkpoint_len = snapshot.checkpoint.len() as u64; - let checkpoint_start = snapshot.end.checked_sub(checkpoint_len) - .ok_or("issue1338_snapshot_invalid: capture unknown")?; - // Validate the exact newly acknowledged checkpoint, not just a readable old - // file or a length. Later appends are deliberately excluded, including half-lines. - diagnostics.seek(SeekFrom::Start(checkpoint_start)) - .map_err(|_| "issue1338_snapshot_invalid: capture unknown")?; - let mut actual = vec![0; snapshot.checkpoint.len()]; - diagnostics.read_exact(&mut actual).map_err(|_| "issue1338_snapshot_invalid: capture unknown")?; - if actual != snapshot.checkpoint { return Err("issue1338_snapshot_invalid: capture unknown"); } - diagnostics.rewind().map_err(|_| "issue1338_snapshot_invalid: capture unknown")?; - let mut application = File::open(source).map_err(|_| "issue1338_application_log_unavailable: capture unknown")?; - // Bound the ordinary log too; it may continue growing during export. Its - // ordinary logger has no flush handshake, which is disclosed in the bundle. - let application_end = application.metadata().map_err(|_| "issue1338_application_log_unavailable: capture unknown")?.len(); - let parent = target.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); - let mut staging = tempfile::NamedTempFile::new_in(parent) - .map_err(|_| "issue1338_staging_failed: capture unknown")?; - let result = (|| -> std::io::Result<()> { - let copied = std::io::copy(&mut (&mut application).take(application_end), &mut staging)?; - if copied != application_end { return Err(std::io::Error::other("short application snapshot")); } - staging.write_all(b"\n--- OpenLess issue1338 diagnostic export v1 ---\napplication_log_tail_capture=unknown\ndiagnostic_bytes=end_at_current_writer_checkpoint\nhistorical_runs=not_current_coverage_unknown\n")?; - let copied = std::io::copy(&mut diagnostics.take(snapshot.end), &mut staging)?; - if copied != snapshot.end { return Err(std::io::Error::other("short diagnostic snapshot")); } - staging.write_all(b"--- end issue1338 diagnostic export ---\n")?; - staging.flush()?; - Ok(()) - })(); - result.map_err(|_| "issue1338_staging_failed: capture unknown")?; - // Never copy/truncate into an existing target. A failed persist leaves the - // old target and both inputs intact; temp cleanup is owned by NamedTempFile. - staging.persist(target).map_err(|_| "issue1338_publish_failed: capture unknown")?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - fn snapshot() -> Snapshot { - let checkpoint = b"{\"run_id\":\"current\"}\n".to_vec(); - Snapshot { end: b"old\n".len() as u64 + checkpoint.len() as u64, checkpoint, incomplete: false } - } - fn fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) { - let dir = tempfile::tempdir().unwrap(); - let app = dir.path().join("openless.log"); - let diag = dir.path().join("session.ndjson"); - let target = dir.path().join("export.log"); - std::fs::write(&app, b"ordinary\n").unwrap(); - let mut bytes = b"old\n".to_vec(); - bytes.extend(snapshot().checkpoint); - std::fs::write(&diag, bytes).unwrap(); - std::fs::write(&target, b"preserve").unwrap(); - (dir, app, diag, target) - } - #[test] - fn single_file_includes_ordinary_history_current_checkpoint_but_no_concurrent_tail() { - let (_dir, app, diag, target) = fixture(); - let mut file = std::fs::OpenOptions::new().append(true).open(&diag).unwrap(); - file.write_all(b"{half-line").unwrap(); - export_with(&app, &diag, &target, || Ok(snapshot())).unwrap(); - let out = std::fs::read_to_string(target).unwrap(); - assert!(out.starts_with("ordinary\n")); - assert!(out.contains("old\n{\"run_id\":\"current\"}\n")); - assert!(out.contains("application_log_tail_capture=unknown")); - assert!(!out.contains("half-line")); - } - #[test] - fn stale_file_missing_file_loss_and_failed_capture_never_publish() { - let (_dir, app, diag, target) = fixture(); - assert!(export_with(&app, &diag, &target, || Err("no current writer")).is_err()); - assert!(export_with(&app, &diag, &target, || { let mut s = snapshot(); s.incomplete = true; Ok(s) }).is_err()); - std::fs::write(&diag, b"old snapshot only").unwrap(); - assert!(export_with(&app, &diag, &target, || Ok(snapshot())).is_err()); - std::fs::remove_file(&diag).unwrap(); - assert!(export_with(&app, &diag, &target, || panic!("missing file cannot capture")).is_err()); - assert_eq!(std::fs::read(target).unwrap(), b"preserve"); - assert_eq!(std::fs::read(app).unwrap(), b"ordinary\n"); - } - #[test] - fn source_targets_and_canonical_aliases_are_rejected_without_capture() { - let (dir, app, diag, _target) = fixture(); - let alias = dir.path().join(".").join("openless.log"); - for target in [&app, &diag, &alias] { - assert!(export_with(&app, &diag, target, || panic!("alias cannot capture")).is_err()); - } - assert_eq!(std::fs::read(app).unwrap(), b"ordinary\n"); - } - #[cfg(unix)] - #[test] - fn symlink_to_either_input_is_rejected_without_capture() { - let (dir, app, diag, _) = fixture(); - for (index, source) in [&app, &diag].into_iter().enumerate() { - let target = dir.path().join(format!("alias-{index}")); - std::os::unix::fs::symlink(source, &target).unwrap(); - assert!(export_with(&app, &diag, &target, || panic!("alias cannot capture")).is_err()); - } - } - #[cfg(target_os = "windows")] - #[test] - fn windows_canonical_path_comparison_is_case_insensitive() { - assert!(same_path(Path::new(r"C:\OpenLess\Logs\openless.log"), - Path::new(r"c:\openless\logs\OPENLESS.LOG")).unwrap()); - } - #[test] - fn hardlink_target_is_replaced_without_truncating_source() { - let (dir, app, diag, _) = fixture(); - let link = dir.path().join("linked.log"); - std::fs::hard_link(&app, &link).unwrap(); - export_with(&app, &diag, &link, || Ok(snapshot())).unwrap(); - assert_eq!(std::fs::read(app).unwrap(), b"ordinary\n"); - assert!(std::fs::read_to_string(link).unwrap().contains("current")); - } - #[test] - fn actual_production_encoder_checkpoint_and_unknowns_reach_the_single_delivered_log() { - let (_dir, app, diag, target) = fixture(); - let (bytes, snapshot) = sink::export_fixture(); - std::fs::write(&diag, bytes).unwrap(); - export_with(&app, &diag, &target, || Ok(snapshot)).unwrap(); - let out = std::fs::read_to_string(target).unwrap(); - let value: serde_json::Value = out.lines().filter_map(|line| serde_json::from_str::(line).ok()) - .find(|v| v["schema"] == "issue1338-export-checkpoint.v1").unwrap(); - assert_eq!(value["run_id"], "r1338"); - assert_eq!(value["capture_health"]["run_id"], "r1338"); - assert_eq!(value["capture_health"]["dropped"], 0); - assert_eq!(value["frontend_coverage"], serde_json::json!({ - "scope": "backend_only", "request_intent": "unknown", "request_send": "unknown", - "response_application": "unknown", "transport_loss": "unknown" - })); - assert_eq!(value["tail_capture"], "unknown"); - assert_eq!(value["historical_runs"], "included_not_current_coverage_unknown"); - assert_eq!(value["feature_enabled"], super::super::enabled()); - } - #[test] - fn failed_publish_preserves_sources_and_existing_target() { - let (dir, app, diag, _) = fixture(); - let target = dir.path().join("directory-target"); - std::fs::create_dir(&target).unwrap(); - assert!(export_with(&app, &diag, &target, || Ok(snapshot())).is_err()); - assert!(target.is_dir()); - assert_eq!(std::fs::read(app).unwrap(), b"ordinary\n"); - assert_eq!(std::fs::read(diag).unwrap().len() as u64, snapshot().end); - } -} diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 26c6de595..1116e7eed 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -72,7 +72,6 @@ mod hotkey; mod hotkey; mod insertion; mod issue1266_debug; -mod issue1338_debug; #[cfg(target_os = "android")] mod style_zip_transfer; mod llm_gemini; @@ -218,7 +217,6 @@ pub(crate) fn set_backend_preferences_for_test( #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - issue1338_debug::initialize(); issue1266_debug::initialize(); tls_crypto::install_tls_crypto_provider(); // Explicit acceptance in a debug bundle bypasses UI, microphone and the @@ -564,8 +562,6 @@ macro_rules! app_invoke_handler_desktop { log_client_error, #[cfg(all(target_os = "windows", feature = "issue-1266-diagnostics"))] issue1266_health, - #[cfg(all(target_os = "windows", feature = "issue-1338-diagnostics"))] - issue1338_health, set_windows_caption_theme, ] }; @@ -1055,12 +1051,7 @@ fn run_desktop() { .show_menu_on_left_click(false) .on_menu_event(move |app, event| match event.id.as_ref() { "toggle" => show_main_window(app), - "quit" => { - let boundary = issue1338_debug::Boundary::enter(issue1338_debug::Probe::P6, issue1338_debug::Kind::TrayQuit); - let _context = issue1338_debug::SyncContext::enter(boundary.operation); - request_quit(app); - boundary.returned(); - }, + "quit" => request_quit(app), id => { if handle_style_tray_menu_event(app, id) { return; @@ -1113,11 +1104,10 @@ fn run_desktop() { Ok(()) }) - .invoke_handler(observe_issue1338_invoke(app_invoke_handler_desktop!())) + .invoke_handler(app_invoke_handler_desktop!()) .build(context) .expect("error while building tauri application") .run(|app, event| { - observe_issue1338_lifecycle(&event); #[cfg(all(target_os = "windows", feature = "issue-1266-diagnostics"))] observe_issue1266_lifecycle(&event); match event { @@ -1177,47 +1167,6 @@ fn run_desktop() { }); } -// [DEBUG-ISSUE-1338] Tauri 2.12.4 ipc::InvokeHandler is Fn(Invoke) -> bool. -// The bool is handled/dispatch return, NOT the eventual async command result. -fn observe_issue1338_invoke( - handler: impl Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static, -) -> impl Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static { - move |invoke| { - use issue1338_debug::{Boundary, Event, Facts, Kind, Operation, Outcome, Probe, SyncContext, WebviewKind}; - if !issue1338_debug::enabled() { return handler(invoke); } - let Some(kind) = Kind::command(invoke.message.command()) else { return handler(invoke); }; - let webview = WebviewKind::from_label(invoke.message.webview_ref().label()); - let boundary = Boundary { operation: Operation::new(Probe::P1), kind }; - boundary.operation.record(Event::Entered, Outcome::Observed, Facts { kind, webview, ..Default::default() }); - let _context = SyncContext::enter(boundary.operation); - let handled = handler(invoke); - boundary.operation.record(Event::DispatchReturned, if handled { Outcome::Ok } else { Outcome::Error }, - Facts { kind, webview, ..Default::default() }); - handled - } -} -// Optional read-only counters, not an emergency transport: a stuck IPC may -// also prevent this command. Copy the append-only session file manually. -#[cfg(all(target_os = "windows", feature = "issue-1338-diagnostics"))] -#[tauri::command] -fn issue1338_health() -> issue1338_debug::Health { issue1338_debug::health() } - -fn observe_issue1338_lifecycle(event: &RunEvent) { - use issue1338_debug::{Boundary, Event, Kind, Outcome, Probe}; - let event = match event { - RunEvent::Ready => Event::Ready, - RunEvent::ExitRequested { .. } => Event::ExitRequested, - RunEvent::Exit => Event::Exit, - RunEvent::WindowEvent { event: tauri::WindowEvent::CloseRequested { .. }, .. } => Event::CloseRequested, - RunEvent::WindowEvent { event: tauri::WindowEvent::Destroyed, .. } => Event::Destroyed, - _ => return, - }; - // No arbitrary window labels; no assertion that Exit proves process termination. - let boundary = Boundary::enter(Probe::P6, Kind::None); - boundary.record(event, Outcome::Observed); - boundary.returned(); -} - #[cfg(all(target_os = "windows", feature = "issue-1266-diagnostics"))] fn observe_issue1266_lifecycle(event: &RunEvent) { use issue1266_debug::{Event, Facts, Kind, Operation, Outcome, Probe}; @@ -1923,7 +1872,6 @@ const QUIT_FORCE_EXIT_GRACE: Duration = Duration::from_secs(3); /// Arm the fallback before requesting graceful exit on explicit user intent. fn request_quit(app: &tauri::AppHandle) { - let boundary = issue1338_debug::Boundary::enter(issue1338_debug::Probe::P6, issue1338_debug::Kind::QuitRequest); std::thread::spawn(|| { std::thread::sleep(QUIT_FORCE_EXIT_GRACE); // Keep the deadline path independent of application locks: synchronous @@ -1933,7 +1881,6 @@ fn request_quit(app: &tauri::AppHandle) { std::process::exit(0); }); app.exit(0); - boundary.returned(); } #[tauri::command] @@ -3373,17 +3320,10 @@ const VOICE_EDIT_WINDOW_HEIGHT: f64 = 620.0; fn ensure_voice_edit_window( app: &AppHandle, ) -> Option> { - use issue1338_debug::{Boundary, Event, Facts, Kind, Outcome, Probe}; - let lookup = Boundary::enter(Probe::P4, Kind::VoiceEditLookup); - let existing = app.get_webview_window("voice-edit"); - lookup.operation.record(Event::Returned, Outcome::Returned, - Facts { kind: Kind::VoiceEditLookup, flag: existing.is_some(), ..Default::default() }); - drop(lookup); - if let Some(window) = existing { + if let Some(window) = app.get_webview_window("voice-edit") { return Some(window); } - let create = Boundary::enter(Probe::P4, Kind::VoiceEditCreate); - let created = WebviewWindowBuilder::new( + match WebviewWindowBuilder::new( app, "voice-edit", WebviewUrl::App("index.html?window=voice-edit".into()), @@ -3400,10 +3340,8 @@ fn ensure_voice_edit_window( .focused(false) .visible(false) .accept_first_mouse(true) - .build(); - create.record(Event::Returned, if created.is_ok() { Outcome::Ok } else { Outcome::Error }); - drop(create); - match created { + .build() + { Ok(window) => { #[cfg(target_os = "macos")] { @@ -3465,15 +3403,8 @@ pub(crate) fn show_voice_edit_window(app: &AppHandle) { #[cfg(target_os = "windows")] { // Preserve the external editor until capture; QA's helper deliberately focuses its WebView. - use issue1338_debug::{Boundary, Event, Kind, Outcome, Probe}; - let focus = Boundary::enter(Probe::P4, Kind::VoiceEditFocusable); - let result = window.set_focusable(false); - focus.record(Event::Returned, if result.is_ok() { Outcome::Ok } else { Outcome::Error }); - drop(focus); - let show = Boundary::enter(Probe::P4, Kind::VoiceEditShow); - let result = window.show(); - show.record(Event::Returned, if result.is_ok() { Outcome::Ok } else { Outcome::Error }); - drop(show); + let _ = window.set_focusable(false); + let _ = window.show(); } #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] let _ = window.show(); diff --git a/openless-all/app/src-tauri/src/permissions.rs b/openless-all/app/src-tauri/src/permissions.rs index cdf9d219b..55a899592 100644 --- a/openless-all/app/src-tauri/src/permissions.rs +++ b/openless-all/app/src-tauri/src/permissions.rs @@ -601,35 +601,9 @@ mod platform { } } -pub use platform::{request_accessibility, request_microphone}; -#[cfg(not(all(target_os = "windows", feature = "issue-1338-diagnostics")))] -pub use platform::{check_accessibility, check_microphone}; - -#[cfg(all(target_os = "windows", feature = "issue-1338-diagnostics"))] -fn observe_native_permission(kind: crate::issue1338_debug::Kind, check: impl FnOnce() -> PermissionStatus) -> PermissionStatus { - use crate::issue1338_debug::{Boundary, Event, Outcome, Probe, SyncContext}; - let boundary = Boundary::enter(Probe::P5, kind); - let _context = SyncContext::enter(boundary.operation); - let status = check(); - let outcome = match status { - PermissionStatus::Granted => Outcome::Granted, - PermissionStatus::Denied => Outcome::Denied, - PermissionStatus::NotDetermined => Outcome::NotDetermined, - PermissionStatus::Restricted => Outcome::Restricted, - PermissionStatus::NotApplicable => Outcome::NotApplicable, - PermissionStatus::NoDevice => Outcome::NoDevice, - }; - boundary.record(Event::Returned, outcome); - status -} -#[cfg(all(target_os = "windows", feature = "issue-1338-diagnostics"))] -pub fn check_accessibility() -> PermissionStatus { - observe_native_permission(crate::issue1338_debug::Kind::Accessibility, platform::check_accessibility) -} -#[cfg(all(target_os = "windows", feature = "issue-1338-diagnostics"))] -pub fn check_microphone() -> PermissionStatus { - observe_native_permission(crate::issue1338_debug::Kind::Microphone, platform::check_microphone) -} +pub use platform::{ + check_accessibility, check_microphone, request_accessibility, request_microphone, +}; #[cfg(all(not(target_os = "macos"), not(target_os = "android")))] pub use platform::has_microphone_input_device; diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index eeace4fbb..e123505ca 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -176,7 +176,7 @@ fn mutate_credentials_with( .map(|gate| gate.begin_mutation()) .transpose() .map_err(sync_access_error)?; - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); // First binding may win the lock after this worker observed no gate. // Such a worker must retry; it cannot write through a newly installed barrier. if was_unbound && sync_write_gate().is_some() { @@ -565,7 +565,7 @@ fn last_vault_read_error_logged_slot() -> &'static Mutex> { } fn store_credentials_cache(root: &CredsRoot) { - *crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock) = Some(root.clone()); + *credentials_cache().lock() = Some(root.clone()); clear_vault_read_error(); } @@ -612,7 +612,7 @@ fn clear_vault_read_error() { #[cfg(test)] fn reset_credentials_cache_for_tests() { - *crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock) = None; + *credentials_cache().lock() = None; clear_vault_read_error(); } @@ -1738,7 +1738,7 @@ fn invalidate_marketplace_token_process_local() { // the command layer's point of view; the atomic also prevents accidental // direct readers from observing the rejected token. MARKETPLACE_TOKEN_REJECTED.store(true, Ordering::SeqCst); - if let Some(root) = crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock).as_mut() { + if let Some(root) = credentials_cache().lock().as_mut() { write_marketplace_github_token(root, None); } #[cfg(target_os = "android")] @@ -1807,7 +1807,7 @@ fn remove_marketplace_token_with( if result.is_err() { // Read/gate failures happen before the update closure. Reassert the local // revocation after any earlier in-flight login has left its vault section. - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); invalidate_marketplace_token_process_local(); } result @@ -2427,7 +2427,7 @@ fn load_credentials_for_sync_binding_with( fn load_credentials_readonly_into_cache_with( loader: impl FnOnce() -> Result, ) -> Result { - if let Some(cached) = crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock).as_ref().cloned() { + if let Some(cached) = credentials_cache().lock().as_ref().cloned() { return Ok(cached); } match loader() { @@ -2444,7 +2444,7 @@ fn load_credentials_readonly_into_cache_with( } fn load_credentials_raw() -> CredsRoot { - if let Some(cached) = crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock).as_ref().cloned() { + if let Some(cached) = credentials_cache().lock().as_ref().cloned() { return cached; } @@ -2473,7 +2473,7 @@ fn load_credentials_raw() -> CredsRoot { } fn load_credentials_for_update_raw() -> Result { - if let Some(cached) = crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock).as_ref().cloned() { + if let Some(cached) = credentials_cache().lock().as_ref().cloned() { return Ok(cached); } @@ -2530,7 +2530,7 @@ fn finish_credential_write(root: &CredsRoot, outcome: Result<()>) -> Result<()> error.downcast_ref::(), Some(VaultCommitFailure::Unchanged) ) { - *crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock) = None; + *credentials_cache().lock() = None; } Err(error) } @@ -3502,7 +3502,7 @@ impl CredentialsVault { pub(crate) fn bind_sync_gate( gate: std::sync::Arc, ) -> Result<()> { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); let mut installed = SYNC_WRITE_GATE.get_or_init(|| Mutex::new(None)).lock(); // Binding is deliberately independent of OS authorization and migration. // Status/read/export lazily inspect the native store under this barrier; @@ -3512,7 +3512,7 @@ impl CredentialsVault { pub(crate) fn export_sync_credentials_readonly( ) -> Result { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); // Cold capture uses the same strict read-only loader as bound getters. // Legacy sources remain readable without migration, even before startup // recovery. Failed OS access leaves the next capture free to retry. @@ -3532,7 +3532,7 @@ impl CredentialsVault { permit: &openless_core::credentials::ExclusivePermit, ) -> Result { require_sync_exclusive(permit)?; - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); capture_sync_credentials_with(load_credentials_for_update) } @@ -3541,7 +3541,7 @@ impl CredentialsVault { permit: &openless_core::credentials::ExclusivePermit, ) -> Result<()> { require_sync_exclusive(permit)?; - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); let current = load_credentials_for_update()?; let replacement = apply_sync_credentials_root(¤t, snapshot)?; // The outer restore transaction owns the exclusive generation/receipt. @@ -3551,7 +3551,7 @@ impl CredentialsVault { pub(crate) fn read_sync_secret( account: &openless_core::credentials::SyncSecretAccount, ) -> Result> { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); read_sync_secret_raw(account) } @@ -3560,7 +3560,7 @@ impl CredentialsVault { value: &openless_core::SecretValue, ) -> Result<()> { validate_sync_key(value)?; - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); if let Some(existing) = read_sync_secret_raw(account)? { if existing == *value { return Ok(()); @@ -3580,7 +3580,7 @@ impl CredentialsVault { pub(crate) fn remove_sync_secret( account: &openless_core::credentials::SyncSecretAccount, ) -> Result<()> { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); remove_sync_secret_raw(account) } @@ -3602,7 +3602,7 @@ impl CredentialsVault { } pub fn load_metadata() -> Result { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); let metadata = credential_metadata(&load_credentials_for_update()?); #[cfg(target_os = "macos")] let metadata = metadata.retired_macos_view(); @@ -3622,7 +3622,7 @@ impl CredentialsVault { } pub fn channel_has_secrets(kind: ChannelKind, id: &str) -> Result { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); Ok(channel_has_secrets( &load_credentials_for_update()?, kind, @@ -3631,7 +3631,7 @@ impl CredentialsVault { } pub fn get(account: CredentialAccount) -> Result> { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); Ok(lookup_account(&load_credentials_for_update()?, account)) } @@ -3647,7 +3647,7 @@ impl CredentialsVault { } pub fn get_for_asr_provider(id: &str, account: CredentialAccount) -> Result> { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); let mut root = load_credentials_for_update()?; root.active.asr = id.to_string(); Ok(lookup_account(&root, account)) @@ -3679,7 +3679,7 @@ impl CredentialsVault { /// This credential deliberately has no generic `CredentialAccount` and is /// excluded from `CredentialsSnapshot`, so frontend IPC can never read it. pub fn get_marketplace_github_token() -> Result> { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); if marketplace_token_is_rejected() { return Ok(None); } @@ -3701,7 +3701,7 @@ impl CredentialsVault { pub fn set_marketplace_github_token(value: &str) -> Result<()> { #[cfg(target_os = "android")] { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); ensure_android_marketplace_legacy_scrubbed()?; *android_marketplace_token().lock() = (!value.trim().is_empty()).then(|| MarketplaceGithubToken(value.to_string())); @@ -3725,7 +3725,7 @@ impl CredentialsVault { #[cfg(target_os = "android")] { invalidate_marketplace_token_process_local(); - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); invalidate_marketplace_token_with(ensure_android_marketplace_legacy_scrubbed) } #[cfg(not(target_os = "android"))] @@ -3741,7 +3741,7 @@ impl CredentialsVault { /// (no "rejected" tombstone) — it is a plain opaque secret, same tier as /// an ASR/LLM API key. pub fn get_cloud_sync_custom_token() -> Result> { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); Ok(lookup_cloud_sync_custom_token( &load_credentials_for_update()?, )) @@ -3766,7 +3766,7 @@ impl CredentialsVault { #[cfg(test)] pub(crate) fn seed_marketplace_github_token_for_tests(value: &str) { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); let mut root = CredsRoot::default(); write_marketplace_github_token(&mut root, Some(value.to_string())); store_credentials_cache(&root); @@ -3777,13 +3777,13 @@ impl CredentialsVault { pub(crate) fn reject_marketplace_github_token_for_tests( durable_delete: impl FnOnce() -> Result<()>, ) -> Result<()> { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); invalidate_marketplace_token_with(durable_delete) } #[cfg(test)] pub(crate) fn reset_marketplace_github_token_for_tests() { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); store_credentials_cache(&CredsRoot::default()); MARKETPLACE_TOKEN_REJECTED.store(false, Ordering::SeqCst); } @@ -3796,7 +3796,7 @@ impl CredentialsVault { /// need the vendor id. Convert here so those call sites stay unchanged. /// Use `get_active_asr_channel_id` when the channel id itself is needed. pub fn get_active_asr() -> String { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); let root = load_credentials(); let id = root.active.asr.clone(); root.providers @@ -3816,7 +3816,7 @@ impl CredentialsVault { /// The current LLM channel's **vendor id (providerType)**. Same rationale as `get_active_asr`. pub fn get_active_llm() -> String { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); let root = load_credentials(); let id = root.active.llm.clone(); root.providers @@ -3828,7 +3828,7 @@ impl CredentialsVault { /// Custom request headers for a specific LLM channel (used by connection tests; use `get_active_llm_extra_headers` when no channel is passed). pub fn get_llm_extra_headers_for_channel(id: &str) -> HashMap { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); let mut root = load_credentials(); root.active.llm = id.to_string(); active_llm_extra_headers(&root) @@ -3836,7 +3836,7 @@ impl CredentialsVault { /// Sampling temperature for a specific LLM channel. pub fn get_llm_temperature_for_channel(id: &str) -> Option { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); let mut root = load_credentials(); root.active.llm = id.to_string(); active_llm_temperature(&root) @@ -3847,7 +3847,7 @@ impl CredentialsVault { /// ASR has long had `get_for_asr_provider`; the LLM side could originally only read the /// "current active" entry, and after channelization must read any card. pub fn get_for_llm_provider(id: &str, account: CredentialAccount) -> Result> { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); let mut root = load_credentials_for_update()?; root.active.llm = id.to_string(); Ok(lookup_account(&root, account)) @@ -3868,7 +3868,7 @@ impl CredentialsVault { } pub fn get_active_omni() -> String { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); load_credentials().omni.active } @@ -3892,7 +3892,7 @@ impl CredentialsVault { } pub fn get_for_omni_provider(id: &str, account: CredentialAccount) -> Result> { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); lookup_omni_account(&load_credentials(), id, account) } @@ -3909,32 +3909,32 @@ impl CredentialsVault { } pub fn get_active_omni_extra_headers() -> HashMap { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); active_omni_extra_headers(&load_credentials()) } pub fn get_active_omni_extra_headers_json() -> Result> { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); active_omni_extra_headers_json(&load_credentials()) } pub fn get_omni_extra_headers_json_for_provider(id: &str) -> Result> { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); omni_extra_headers_json(&load_credentials(), id) } pub fn get_active_omni_temperature() -> Option { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); active_omni_temperature(&load_credentials()) } pub fn get_active_omni_temperature_string() -> Option { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); active_omni_temperature_string(&load_credentials()) } pub fn get_omni_temperature_string_for_provider(id: &str) -> Option { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); omni_temperature_string(&load_credentials(), id) } @@ -3987,22 +3987,22 @@ impl CredentialsVault { } pub fn get_active_llm_extra_headers() -> HashMap { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); active_llm_extra_headers(&load_credentials()) } pub fn get_active_llm_extra_headers_json() -> Result> { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); active_llm_extra_headers_json(&load_credentials()) } pub fn get_active_llm_temperature() -> Option { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); active_llm_temperature(&load_credentials()) } pub fn get_active_llm_temperature_string() -> Option { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); active_llm_temperature_string(&load_credentials()) } @@ -4019,7 +4019,7 @@ impl CredentialsVault { } pub fn get_llm_protocol_option(id: Option<&str>, account: &str) -> Result> { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); let mut root = load_credentials_for_update()?; let id = id.unwrap_or(&root.active.llm).to_string(); match root.providers.llm.get_mut(&id) { @@ -4076,7 +4076,7 @@ impl CredentialsVault { } pub fn snapshot_for_pipeline(include_omni: bool) -> CredentialsSnapshot { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); let root = load_credentials(); credentials_snapshot(&root, include_omni) } @@ -4084,7 +4084,7 @@ impl CredentialsVault { pub(crate) fn configuration_snapshot( include_omni: bool, ) -> Result { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); configuration_snapshot_with(include_omni, load_credentials_for_update) } } @@ -4811,7 +4811,7 @@ mod tests { .context("read Android credential envelope")) }); assert!(lookup_marketplace_github_token(&first).is_none()); - assert!(crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock).is_none()); + assert!(credentials_cache().lock().is_none()); let first_error = CredentialsVault::last_read_error().expect("vault error should be recorded"); assert!( @@ -4830,13 +4830,13 @@ mod tests { let second = load_credentials_into_cache_with(|| load_android_credentials_from_path(&path)); assert!(lookup_marketplace_github_token(&second).is_none()); - assert!(crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock).is_some()); + assert!(credentials_cache().lock().is_some()); assert!( CredentialsVault::last_read_error().is_none(), "successful read must clear the last vault error" ); assert_android_secret_unrecoverable(&path, "gho_legacy_startup_secret"); - *crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock) = Some(CredsRoot::default()); + *credentials_cache().lock() = Some(CredsRoot::default()); let _ = std::fs::remove_dir_all(dir); } @@ -4861,7 +4861,7 @@ mod tests { .context("read Android credential envelope")) }) .expect_err("mutations must not receive a default root to persist"); - assert!(crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock).is_none()); + assert!(credentials_cache().lock().is_none()); let error = CredentialsVault::last_read_error().expect("vault error should be recorded"); assert!( error.contains("temporarily unavailable"), @@ -5565,12 +5565,12 @@ mod encrypted_sync_tests { }) .unwrap_err(); assert!(failure.to_string().contains("fixture native access denied")); - assert!(crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock).is_none()); + assert!(credentials_cache().lock().is_none()); let recorded = CredentialsVault::last_read_error().unwrap(); // A cache hit itself is not a successful OS read. Seed only the cache // slot, without the successful-load/save helper's error-latch update. - *crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock) = Some(root_with_secret("fixture-cached")); + *credentials_cache().lock() = Some(root_with_secret("fixture-cached")); let cached = load_credentials_readonly_into_cache_with(|| panic!("must use cache")).unwrap(); assert_eq!( @@ -5582,7 +5582,7 @@ mod encrypted_sync_tests { Some(recorded.as_str()) ); - *crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock) = None; + *credentials_cache().lock() = None; let loaded = load_credentials_readonly_into_cache_with(|| { Ok(root_with_secret("fixture-retry-succeeded")) }) @@ -5591,7 +5591,7 @@ mod encrypted_sync_tests { loaded.providers.asr["stable-channel"].apiKey.as_deref(), Some("fixture-retry-succeeded") ); - assert!(crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock).is_some()); + assert!(credentials_cache().lock().is_some()); assert!(CredentialsVault::last_read_error().is_none()); reset_credentials_cache_for_tests(); } @@ -6082,13 +6082,13 @@ mod encrypted_sync_tests { store_credentials_cache(&old); assert!(finish_credential_write(&new, Err(VaultCommitFailure::Unchanged.into())).is_err()); assert_eq!( - crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock).as_ref().unwrap().providers.asr["stable-channel"] + credentials_cache().lock().as_ref().unwrap().providers.asr["stable-channel"] .apiKey .as_deref(), Some("old") ); assert!(finish_credential_write(&new, Err(VaultCommitFailure::Unknown.into())).is_err()); - assert!(crate::issue1338_debug::lock(credentials_cache(), crate::issue1338_debug::Kind::CacheLock).is_none()); + assert!(credentials_cache().lock().is_none()); let count = Cell::new(0); let loaded = load_credentials_into_cache_with(|| { count.set(count.get() + 1); diff --git a/openless-all/app/src-tauri/src/persistence/credentials/android_transfer.rs b/openless-all/app/src-tauri/src/persistence/credentials/android_transfer.rs index 52eec8088..c9239e372 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials/android_transfer.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials/android_transfer.rs @@ -169,7 +169,7 @@ fn merge_channels( impl CredentialsVault { pub(crate) fn export_android_channels(secrets: bool) -> Result { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); let root = load_credentials_for_update()?; let channels = Channels { asr: root @@ -206,7 +206,7 @@ impl CredentialsVault { selection: &serde_json::Value, credentials: Option<&serde_json::Value>, ) -> Result { - let _guard = crate::issue1338_debug::lock(credentials_lock(), crate::issue1338_debug::Kind::CredentialsLock); + let _guard = credentials_lock().lock(); let root = load_credentials_for_update()?; let mut channels = Channels::default(); for (slot, field) in [("asr", "activeAsrProvider"), ("llm", "activeLlmProvider")] { diff --git a/openless-all/app/src-tauri/src/tauri_coordinator_host.rs b/openless-all/app/src-tauri/src/tauri_coordinator_host.rs index 2154d29c4..12fe1f216 100644 --- a/openless-all/app/src-tauri/src/tauri_coordinator_host.rs +++ b/openless-all/app/src-tauri/src/tauri_coordinator_host.rs @@ -1095,28 +1095,22 @@ impl TauriCapsuleWindow { where F: FnOnce(Self) + Send + 'static, { - let dispatch1338 = crate::issue1338_debug::MainDispatch::new(crate::issue1338_debug::Kind::CapsuleDispatch); - let task = move |capsule| dispatch1338.wrap(move || task(capsule))(); - let result1338 = (|| { - #[cfg(all(target_os = "windows", feature = "issue-1266-diagnostics"))] - { - let dispatch = crate::issue1266_debug::MainDispatch::new(kind); - let capsule = dispatch.clone_handle(|| self.clone()); - let result = self.app.run_on_main_thread(dispatch.wrap(move || task(capsule))) - .map_err(|error| error.to_string()); - dispatch.dispatched(result.is_ok()); - result - } - #[cfg(not(all(target_os = "windows", feature = "issue-1266-diagnostics")))] - { - let _ = kind; - let capsule = self.clone(); - self.app.run_on_main_thread(move || task(capsule)) - .map_err(|error| error.to_string()) - } - })(); - dispatch1338.dispatched(result1338.is_ok()); - result1338 + #[cfg(all(target_os = "windows", feature = "issue-1266-diagnostics"))] + { + let dispatch = crate::issue1266_debug::MainDispatch::new(kind); + let capsule = dispatch.clone_handle(|| self.clone()); + let result = self.app.run_on_main_thread(dispatch.wrap(move || task(capsule))) + .map_err(|error| error.to_string()); + dispatch.dispatched(result.is_ok()); + result + } + #[cfg(not(all(target_os = "windows", feature = "issue-1266-diagnostics")))] + { + let _ = kind; + let capsule = self.clone(); + self.app.run_on_main_thread(move || task(capsule)) + .map_err(|error| error.to_string()) + } } fn restore_translation_menu(&self, owner: Option<(&str, openless_core::SessionId)>) -> Result { @@ -2094,8 +2088,6 @@ impl TauriCoordinatorHost { where F: FnOnce() + Send + 'static, { - let dispatch1338 = crate::issue1338_debug::MainDispatch::new(crate::issue1338_debug::Kind::HostDispatch); - let task = dispatch1338.wrap(task); #[cfg(feature = "issue-1266-diagnostics")] let dispatch = crate::issue1266_debug::MainDispatch::new(kind); #[cfg(feature = "issue-1266-diagnostics")] @@ -2105,7 +2097,6 @@ impl TauriCoordinatorHost { if MAIN_THREAD.get() == Some(&std::thread::current().id()) { task(); - dispatch1338.dispatched(true); #[cfg(feature = "issue-1266-diagnostics")] dispatch.dispatched(true); return; @@ -2117,7 +2108,6 @@ impl TauriCoordinatorHost { } else { false }; - dispatch1338.dispatched(queued); #[cfg(feature = "issue-1266-diagnostics")] dispatch.dispatched(queued); #[cfg(not(feature = "issue-1266-diagnostics"))] @@ -2319,29 +2309,23 @@ impl TauriCoordinatorHost { where F: FnOnce() + Send + 'static, { - let dispatch1338 = crate::issue1338_debug::MainDispatch::new(crate::issue1338_debug::Kind::HostDispatch); - let task = dispatch1338.wrap(task); - let result1338 = (|| { - #[cfg(all(target_os = "windows", feature = "issue-1266-diagnostics"))] - { - use crate::issue1266_debug::{Kind, MainDispatch}; - let dispatch = MainDispatch::new(Kind::HostDispatch); - let app = dispatch.clone_handle(|| self.app()); - let result = app.ok_or_else(|| "Tauri AppHandle is not bound".to_string()) - .and_then(|app| app.run_on_main_thread(dispatch.wrap(task)) - .map_err(|error| error.to_string())); - dispatch.dispatched(result.is_ok()); - result - } - #[cfg(not(all(target_os = "windows", feature = "issue-1266-diagnostics")))] - { - let app = self.app() - .ok_or_else(|| "Tauri AppHandle is not bound".to_string())?; - app.run_on_main_thread(task).map_err(|error| error.to_string()) - } - })(); - dispatch1338.dispatched(result1338.is_ok()); - result1338 + #[cfg(all(target_os = "windows", feature = "issue-1266-diagnostics"))] + { + use crate::issue1266_debug::{Kind, MainDispatch}; + let dispatch = MainDispatch::new(Kind::HostDispatch); + let app = dispatch.clone_handle(|| self.app()); + let result = app.ok_or_else(|| "Tauri AppHandle is not bound".to_string()) + .and_then(|app| app.run_on_main_thread(dispatch.wrap(task)) + .map_err(|error| error.to_string())); + dispatch.dispatched(result.is_ok()); + result + } + #[cfg(not(all(target_os = "windows", feature = "issue-1266-diagnostics")))] + { + let app = self.app() + .ok_or_else(|| "Tauri AppHandle is not bound".to_string())?; + app.run_on_main_thread(task).map_err(|error| error.to_string()) + } } pub(crate) fn spawn(&self, future: F) -> tauri::async_runtime::JoinHandle