From db465ec26ff8f401f949833cef0dc3e8ea39d59e Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:23:12 +0800 Subject: [PATCH 1/3] feat(state): introduce independently reviewable mutation transactions Problem: Task-length write claims cannot independently represent permission, assignment and the physical mutation lifetime across cooperating runtimes. Root cause: Shared admission, input preconditions, cancellation and release have no common short-transaction owner that callers can adopt independently. Fix: Extract the mutation resource/queue/capacity owner and content versions from the preserved concurrency implementation. Add minimal non-blocking legacy claim compatibility and exception-safe publication cleanup. Keep all runtime, isolation, hook, controller and default activation outside this review slice. Document authority, actual sinks, compatibility and unenforced boundaries, and map the remaining source into dependent review units. Normalize an inherited design-document lint prerequisite without relaxing its baseline. Verification: make check, full lint and root vet pass. Owning-package race passes, including real subprocess death and a strengthened cleanup regression that fails without the cleanup via compiler overlay and passes with it. Builtin passes. The full boot invocation reaches its 600-second package alarm; all 811 top-level tests close in four inventory-verified groups (808 pass, 3 conditional skips, no missing/unexpected/failing selected tests). Linux and Windows amd64 test binaries cross-compile; native platform and hosted CI evidence are separate gates. The remaining root-package sweep is in progress. Refs #14025 Refs #14133 --- docs/CONCURRENT_MUTATIONS_REVIEW_SERIES.md | 71 ++++ docs/MUTATION_COORDINATOR_SECURITY.md | 160 +++++++++ docs/design/FIRST_RUN_WORKSPACE.md | 19 +- internal/base/fileutil/content_version.go | 77 ++++ .../base/fileutil/content_version_test.go | 70 ++++ .../state/mutation/callback_failure_test.go | 123 +++++++ internal/state/mutation/capacity.go | 163 +++++++++ .../state/mutation/capacity_process_test.go | 41 +++ .../state/mutation/capacity_scope_test.go | 56 +++ internal/state/mutation/capacity_test.go | 330 ++++++++++++++++++ internal/state/mutation/case_fold_test.go | 39 +++ .../mutation/compatibility_phase_test.go | 131 +++++++ internal/state/mutation/compatibility_test.go | 27 ++ internal/state/mutation/completion.go | 86 +++++ internal/state/mutation/completion_test.go | 55 +++ internal/state/mutation/continuation.go | 37 ++ internal/state/mutation/coordinator.go | 280 +++++++++++++++ internal/state/mutation/coordinator_test.go | 172 +++++++++ internal/state/mutation/doc.go | 11 + internal/state/mutation/fairness_test.go | 67 ++++ internal/state/mutation/notice_test.go | 117 +++++++ internal/state/mutation/process_test.go | 77 ++++ internal/state/mutation/registry.go | 202 +++++++++++ internal/state/mutation/resources.go | 190 ++++++++++ internal/state/mutation/stale_wait_test.go | 78 +++++ internal/state/mutation/try_test.go | 38 ++ internal/state/mutation/types.go | 102 ++++++ internal/state/mutation/version.go | 21 ++ .../mutation/workspace_resources_test.go | 113 ++++++ .../state/workspacelease/claim_attempt.go | 45 +++ .../workspacelease/claim_attempt_test.go | 60 ++++ .../state/workspacelease/claim_panic_test.go | 46 +++ internal/state/workspacelease/paths.go | 17 +- 33 files changed, 3114 insertions(+), 7 deletions(-) create mode 100644 docs/CONCURRENT_MUTATIONS_REVIEW_SERIES.md create mode 100644 docs/MUTATION_COORDINATOR_SECURITY.md create mode 100644 internal/base/fileutil/content_version.go create mode 100644 internal/base/fileutil/content_version_test.go create mode 100644 internal/state/mutation/callback_failure_test.go create mode 100644 internal/state/mutation/capacity.go create mode 100644 internal/state/mutation/capacity_process_test.go create mode 100644 internal/state/mutation/capacity_scope_test.go create mode 100644 internal/state/mutation/capacity_test.go create mode 100644 internal/state/mutation/case_fold_test.go create mode 100644 internal/state/mutation/compatibility_phase_test.go create mode 100644 internal/state/mutation/compatibility_test.go create mode 100644 internal/state/mutation/completion.go create mode 100644 internal/state/mutation/completion_test.go create mode 100644 internal/state/mutation/continuation.go create mode 100644 internal/state/mutation/coordinator.go create mode 100644 internal/state/mutation/coordinator_test.go create mode 100644 internal/state/mutation/doc.go create mode 100644 internal/state/mutation/fairness_test.go create mode 100644 internal/state/mutation/notice_test.go create mode 100644 internal/state/mutation/process_test.go create mode 100644 internal/state/mutation/registry.go create mode 100644 internal/state/mutation/resources.go create mode 100644 internal/state/mutation/stale_wait_test.go create mode 100644 internal/state/mutation/try_test.go create mode 100644 internal/state/mutation/types.go create mode 100644 internal/state/mutation/version.go create mode 100644 internal/state/mutation/workspace_resources_test.go create mode 100644 internal/state/workspacelease/claim_attempt.go create mode 100644 internal/state/workspacelease/claim_attempt_test.go create mode 100644 internal/state/workspacelease/claim_panic_test.go diff --git a/docs/CONCURRENT_MUTATIONS_REVIEW_SERIES.md b/docs/CONCURRENT_MUTATIONS_REVIEW_SERIES.md new file mode 100644 index 0000000000..d4edf90dfe --- /dev/null +++ b/docs/CONCURRENT_MUTATIONS_REVIEW_SERIES.md @@ -0,0 +1,71 @@ +--- +owner: @SivanCola +backup: @esengine +status: active +reviewed: 2026-10-24 +--- + +# Concurrent mutation review series / 并发写入分批审查 + +## Purpose / 目的 + +The complete implementation remains pinned at +`467a61668a0781cab171536158536283d931e4d9` in #14133 as a source reference. +It is being split following the maintainer's review-size request. +The source PR's validation is historical evidence, not qualification of a slice. + +完整实现保留在 #14133 的上述提交。本系列按维护者要求拆分;原实现的测试记录 +保留,但每一批都必须独立构建、检查及测试,不能继承整体分支的通过结论。 + +## Sequence and review boundaries / 顺序与边界 + +| Order / 顺序 | Review unit / 审查单元 | Prerequisite and activation / 前置条件及接入 | +| --- | --- | --- | +| 1 | Mutation admission primitive, content versions, legacy claim compatibility | Current slice; library only. No tool/controller/settings activation. | +| 2 | Tool effect descriptions and per-actor read-version provenance | Depends on 1. Review path grants, approval ordering, overlays and same-file stale-view regressions. | +| 3 | Shared tool/agent transaction assembly | Depends on 2. Review parent/child/fleet/dynamic paths, refused preparation and physical completion. | +| 4 | Source snapshots and private worktree preparation | Independent platform layer where possible. Review Git common-directory operations, modes, symlinks, untracked and ignored inputs. | +| 5 | Journaled per-file application and conditional recovery | Depends on 1 and 4. Review manifest containment, preimages, identity, failure injection, conditional rollback and idempotence. | +| 6 | Frozen check inputs and verification receipts | Review input observation, environment identity, stale evidence and unavailable backend behavior before integration activation. | +| 7 | Durable isolated task records and lifecycle | Depends on 4 and 6. Split preparation/execution from recovery/retention if either diff becomes too broad. | +| 8 | Task/fleet/background and nested ownership | Depends on 3 and 7. Review budgets, continuation, direct-parent identity, cancellation, restart and cross-version fences. | +| 9 | Root conversations and source integration | Depends on 5–8. Review current grants, source drift, controlled checks and recovery blocking together. | +| 10 | Controlled portable verifier and confined shell/hooks | Separate written command/authority security review; no unsupported-platform enforcement claim. | +| 11 | Controller/wire/frontends and migration/default selection | Activate only after required lower-layer review and platform evidence; preserve legacy settings and active runtime protocols. | +| 12 | Product/process performance and final acceptance | Reconcile every source delta, tests and compatibility evidence on the combined accepted tree. | + +表中是依赖关系及审查边界,不是承诺将每行所有文件一次性提交。若一行仍然 +包含多个独立机制,应继续拆分。前置接口经审查确定后,再提交对应的接入批次。 +优先使用直接面向 `studio` 的独立 PR;需要堆叠时明确实际基分支及依赖。 + +## Extraction and security rules / 拆分与安全规则 + +1. Each slice MUST build and run its own tests without unsubmitted sibling + patches. No lint baseline, safety gate or coverage floor is relaxed to make + an extraction pass. +2. Every PR MUST state Cause, Blast radius, Neighbouring behaviours tested and + Why this layer, plus its actual base/head and local/hosted evidence. +3. Sensitive slices MUST document input provenance, authority, filesystem or + command sinks, validation, failure recovery and explicit unenforced bounds. +4. Original CodeQL findings remain unresolved until assessed at their actual + sources and sinks. Moving code between PRs is not security remediation. +5. The source branch MUST remain recoverable. Each extracted file or hunk is + accounted for as kept, adapted, deferred or intentionally dropped; the + combined final tree is compared with the pinned source plus upstream changes. +6. Primitive API simplifications in slice 1 are intentional: use `Options`, + lease methods and explicit-context version reads. Runtime-only context + conveniences and legacy recovery admission hooks are deferred to their users. + +每批必须独立验证,不降低门禁;安全说明不能用“旧分支测过”替代。拆分时保留 +完整来源,并记录适配或延期的差异;最后对合并后的代码树做完整性核对。第一批 +去掉尚无调用者的便利函数,后续接入时按已审查接口适配,不复制旧 API。 + +## First-slice review / 第一批审查入口 + +Read [the coordinator security review](MUTATION_COORDINATOR_SECURITY.md), then +`internal/state/mutation/doc.go`, `coordinator.go`, `registry.go`, `resources.go`, +`capacity.go` and `completion.go`. The compatibility change is limited to +`workspacelease/claim_attempt.go` and its existing `paths.go` publication owner. + +先阅读安全审查及包说明,再审查资源申请、登记、身份、容量及释放。旧协议的 +调整限于独立短申请及其发布清理,不接入运行时恢复屏障或修改旧锁语义。 diff --git a/docs/MUTATION_COORDINATOR_SECURITY.md b/docs/MUTATION_COORDINATOR_SECURITY.md new file mode 100644 index 0000000000..70aa7daa42 --- /dev/null +++ b/docs/MUTATION_COORDINATOR_SECURITY.md @@ -0,0 +1,160 @@ +--- +owner: @SivanCola +backup: @esengine +status: active +reviewed: 2026-10-24 +--- + +# Mutation coordinator security review / 写入事务协调器安全审查 + +## Scope and decision / 范围与结论 + +This is the author's security analysis of the first review slice of #14133, +extracted from source commit `467a61668a0781cab171536158536283d931e4d9`. +It is not an independent approval or a disposition of that PR's CodeQL findings. + +The slice adds `internal/state/mutation`, content-version reads, and the minimum +non-blocking `workspacelease` compatibility claim API. It introduces no production +callers, shell launcher, hooks, worktree integration, settings or default change. + +这是 #14133 第一批改动的作者安全分析,不代表独立审批,也不代表原 PR 的 +CodeQL 告警已解决。本批只增加事务基础库、内容版本读取和旧协议兼容申请接口; +没有接入生产调用者、命令执行、hooks、worktree 整合或设置,默认行为保持原样。 + +## Threat model and authority / 威胁模型与授权 + +The protected property is exclusion among cooperating Reasonix processes that +share one trusted state directory. A caller may request conflicting paths, +cancel, fail, panic in a callback, retain a stale input version, or die while +queued/active. + +The coordinator must not grant partial sets, steal a live lease, +release a successor, silently accept stale versions, or erase unreadable records. + +`New(root, stateDir, ...)` is a host API. Both arguments and admission callbacks +must come from the authorized host, not directly from model-controlled JSON. + +`Intent.Paths` may contain authorized paths outside `root`: root is a resolution +base, not an access-control boundary. Canonicalization establishes identity, +**not permission**. The host must resolve the actual effect and complete grants +before acquisition. + +`Options.AdmissionCheck` can reject admission under the +lease; omitting it does not create an implicit permission check. + +保护对象是共享可信状态目录、遵守协议的 Reasonix 进程。请求可能冲突、取消、 +过期或崩溃;不得部分占用、夺取有效租约、释放后继事务或删除无法理解的记录。 + +构造参数和回调必须来自已授权宿主。资源可以是工作区之外已获授权的路径: +路径规范化只确认身份,不授予读写权限。权限检查及完整影响范围仍由宿主负责。 + +Not protected: malicious modification of the state directory by the same OS +user, non-cooperating filesystem writers, arbitrary symlink/hardlink replacement +after validation, filesystem/OS failure, databases, network services, credentials +or ports. Advisory locks are not a sandbox. + +Different state directories do not +coordinate even if paths happen to name the same file. Never remove lock files +to resolve contention. The state directory must not be exposed as an execution +workspace or be placed in an untrusted/shared writable directory. + +Existing named-file hardlinks are checked with `os.SameFile`. Directory resource +sets do not enumerate aliases outside their trees. A directory lease therefore +cannot establish complete alias coverage; future opaque-write callers must +constrain such aliases or add conservative coordination before claiming it. + +不覆盖同一系统用户恶意篡改状态目录、不遵守协议的写入者、校验后的任意路径 +替换、系统故障及外部服务。不同状态目录不互相协调;不能删除锁文件制造并发。 +状态目录不得作为执行工作区,也不得放在不可信的共享可写目录中。 + +## Data flows and checks / 数据流及检查 + +| Input / 输入 | Sink and guard / 访问位置及约束 | +| --- | --- | +| Host `stateDir` | `New` creates `mutations-v1` with mode 0700; records use 0600 and atomic replacement. Existing directory ownership/ACLs are a host precondition, not revalidated here. Windows modes are not an ACL sandbox. | +| Host root and intent paths | `resources` uses existing `CanonicalWritePath`, resolves existing ancestors, refuses ambiguous leaf links/Windows spellings, sorts and deduplicates. The locked validation repeats resolution and compares the complete set. It intentionally permits authorized extra roots. | +| Expected-content map | Keys must be absolute and covered by held resources. `heldWithin` accepts case-folded coverage only when the filesystem confirms the same existing prefix; conflict detection may conservatively over-serialize. `validateExpected` fully hashes inputs again after grant, regardless of the metadata-only waiting optimization. | +| Content read path | `ReadContentVersion` is a low-level arbitrary-path read API, not a permission boundary. It hashes regular bytes with cancellation, identifies absent/directory/link objects and rejects unsupported kinds. File identity/size/mtime changes during reads refuse. Link versions hash link text; mutation path resolution refuses ambiguous leaf links. A directory version is not a recursive snapshot. | +| Transaction filenames | IDs originate from `crypto/rand.Text`, not session/execution/call IDs. Registry filenames come from directory entries, whose path components cannot contain separators. A loaded record must match its filename, supported resource schema and nonzero queue order before any stale-owner removal. Diagnostic identities are serialized as JSON, never evaluated as commands. | +| Capacity identity | Opaque bounded strings, positive units/limits, no duplicates; inconsistent limits refuse. IDs do not become filesystem paths. Admission is atomic with paths; path-blocked requests do not consume execution slots. | +| Legacy claims | `TryClaimPaths` rejects empty/unresolvable sets, uses the existing workspace and claim locks, publishes all paths as one claim and returns an idempotent release. Probe requests publish nothing. Failure/panic before handoff removes only the newly created claim. | +| Continuation | Copies resource identities into a fresh acquisition; it does not transfer a live lease or reuse expected versions/approval. The future caller must re-establish its authorization and content preconditions. | + +上表逐项区分宿主路径、预期版本、随机事务身份及容量身份。文件访问 API 并不 +自行形成权限边界。等待期间的元数据优化不能替代获得租约后的完整内容校验。 +目录版本不是递归快照,续跑对象也不携带旧授权或旧审批。 + +## Ordering, liveness and failure / 顺序、存活及失败 + +The registry guard covers queue inspection and publication, not tool execution. +Compatibility discovery runs outside it and is followed by a locked recheck. + +Earlier overlapping requests retain priority; disjoint resources may proceed. +Read-only/external effects cannot smuggle paths or expected versions through an +empty set. Capacity-only leases cannot authorize file mutations. + +A unique live OS lock, not a PID or elapsed timeout, establishes transaction +liveness. A wait timeout abandons the waiter only. Process death releases OS +ownership; a later registry pass removes a structurally valid dead record. + +Unknown/corrupt schemas refuse and retain the bytes, even if apparently stale. +No migration or repair is attempted. + +Nested leases cannot widen their parent's +resources or end it. Duplicate `End` calls cannot release a successor's random +transaction identity. + +`HoldUntil` delays release until all registered physical +execution completions close; a caller that forgets completion can block progress. + +登记临界区只保护排队及发布。旧协议检查在临界区之外运行,随后再次校验。 +超时仅终止等待;真实存活由 OS 锁判断。未知版本及损坏记录保留并拒绝继续。 +嵌套调用不能扩大范围或释放父租约;重复结束不能释放后继事务。实际执行尚未 +结束时必须登记完成信号,否则不能声称取消已安全释放写入权。 + +This layer does not perform writes on the caller's behalf, maintain durable +write preimages, or recover partially changed files. `Outcome` is the caller's +reported classification, not proof of rollback, commit or verification. A future +runtime/integration PR must establish those additional contracts explicitly. + +本层不代写文件,不保存写前映像,也不恢复部分变更。结果枚举是调用者报告, +不是提交、回滚或验证成功的证明;这些保证属于后续运行时及整合层。 + +## Compatibility and security gates / 兼容及安全门禁 + +| Contract / 协议 | Existing behavior / 旧行为 | This slice / 本批行为 | +| --- | --- | --- | +| Legacy workspace claims | Existing long-lived owners and optimistic bypasses | Format unchanged; independent short claims interoperate when callers opt in. An old bypass cannot be repaired by this library alone. | +| Mutation record v1 | Absent on older studio | Path-only records; live/dead checked using OS lock. | +| Mutation record v2 | Unsupported by v1-only readers | Carries capacities; unsupported versions fail closed and retain records. No active mixed-version safety claim beyond these readers. | +| Settings, transcripts, results and wire | Existing schemas | Untouched; no new defaults or automatic migration. | + +Required review includes actual first-slice CodeQL results; inherited #14133 +alerts are not blanket false positives. This slice has no production command +execution sink. The two old command-sink discussions remain obligations of the +later shell/hook PRs. + +Before runtime activation, independently review all effect +resolvers, access checks, snapshot/version provenance and physical completion +callers; passing this library's tests is insufficient for that activation. + +必须核对本批实际 CodeQL 结果,不能把原 PR 的告警批量认定为误报。命令及 hook +执行的数据流由后续对应 PR 单独审查。接入运行时之前,还必须审查影响解析、 +权限、版本来源和实际执行完成回调;基础库测试通过不代表上层已安全接入。 + +## Verification map / 验证对应表 + +- Complete-set cancellation and no widening: `coordinator_test.go`, + `capacity_scope_test.go`, `try_test.go`. +- Conflict fairness and independent progress: `fairness_test.go`, + `capacity_test.go`, `compatibility_phase_test.go`. +- Stale reads, aliases and file identity: `stale_wait_test.go`, + `case_fold_test.go`, `workspace_resources_test.go`, `content_version_test.go`. +- Refusal/panic and completion ownership: `callback_failure_test.go`, + `completion_test.go`, `workspacelease/claim_panic_test.go`. +- Real subprocess death and protocol preservation: `process_test.go`, + `capacity_process_test.go`, `compatibility_test.go`. + +上述测试分别验证完整资源申请、公平排队、旧视图拒绝、别名身份、异常清理、 +实际执行完成、真实进程退出及未知协议保留。平台测试和 CI 结果在每个 PR 中 +按实际来源报告,不继承完整实现的旧验收结论。 diff --git a/docs/design/FIRST_RUN_WORKSPACE.md b/docs/design/FIRST_RUN_WORKSPACE.md index 5a616a5d9d..432a4b7d3a 100644 --- a/docs/design/FIRST_RUN_WORKSPACE.md +++ b/docs/design/FIRST_RUN_WORKSPACE.md @@ -1,13 +1,18 @@ --- -owner: @Oliver-Rui -status: proposed +owner: @esengine +backup: @SivanCola +author: @Oliver-Rui +status: active +reviewed: 2026-10-24 --- # First run without a picked workspace Status: proposed. Nothing in this document is shipped unless §8 says so. It lives under `docs/design/` so the product-doc retrieval corpus (`docs/*.md`) cannot present it to a model as a feature. -Reference: how MiMo Desktop (observed 2026-10-24 on a machine running it) handles the same three first-run decisions — connect a model, pick a workspace, start a session. Its behaviour below is read off installed artifacts (config schema, directory layout, state store), not source; the mapping onto this repository in §6 is grounded in source. +Reference: how MiMo Desktop (observed 2026-10-24 on a machine running it) handles the same three first-run decisions — connect a model, pick a workspace, start a session. + +Its behaviour below is read off installed artifacts (config schema, directory layout, state store), not source; the mapping onto this repository in §6 is grounded in source. ## 1. The problem @@ -19,9 +24,13 @@ Studio's first launch asks three things at once, and only one of them is honestl | Start a session | the composer is reachable as soon as the workbench opens | yes | | Pick a workspace | `pickLaunchWorkspace(cwd, appExe, remembered, home)` — with nothing remembered, the first pane's root is the **user's home directory** | no | -The fallback chain in `cmd/reasonix-studio-host/launchroot.go` is careful about *which* cwd it trusts — a Start-menu launch inherits the install directory, and taking that as a project "puts the app's own folder in the sidebar again after every removal" — but its last resort is the home directory. That folder was never chosen as a project. It is broad, it is sensitive, and it is not represented in the sidebar as anything the user picked, so a session running there reads as a temporary session: not in the user's project, scoped to a folder they did not choose, and removing it does not stick. +The fallback chain in `cmd/reasonix-studio-host/launchroot.go` is careful about *which* cwd it trusts — a Start-menu launch inherits the install directory, and taking that as a project "puts the app's own folder in the sidebar again after every removal" — but its last resort is the home directory. -Everything else is already in place for a decoupled first run. Session transcripts persist in the global store (`SessionDir()` = `/sessions`), so a session without a chosen project is not lost. Each logical session already owns a private scratch directory (`SessionTemp`, rotated per session, deleted when it ends, surfaced to the model as `$TMPDIR`). Provider setup already refuses to block. The workspace is the last coupled decision. +That folder was never chosen as a project. It is broad, it is sensitive, and it is not represented in the sidebar as anything the user picked, so a session running there reads as a temporary session: not in the user's project, scoped to a folder they did not choose, and removing it does not stick. + +Everything else is already in place for a decoupled first run. Session transcripts persist in the global store (`SessionDir()` = `/sessions`), so a session without a chosen project is not lost. + +Each logical session already owns a private scratch directory (`SessionTemp`, rotated per session, deleted when it ends, surfaced to the model as `$TMPDIR`). Provider setup already refuses to block. The workspace is the last coupled decision. ## 2. What MiMo does instead diff --git a/internal/base/fileutil/content_version.go b/internal/base/fileutil/content_version.go new file mode 100644 index 0000000000..8ccfc2bdf7 --- /dev/null +++ b/internal/base/fileutil/content_version.go @@ -0,0 +1,77 @@ +package fileutil + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "io" + "os" +) + +type ContentVersion struct { + Kind string `json:"kind"` + Digest string `json:"digest,omitempty"` + Mode uint32 `json:"mode,omitempty"` + BufferVersion string `json:"bufferVersion,omitempty"` +} + +var ErrContentChanged = errors.New("file changed while reading content version") + +func ReadContentVersion(ctx context.Context, path string) (ContentVersion, error) { + if err := ctx.Err(); err != nil { + return ContentVersion{}, err + } + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return ContentVersion{Kind: "absent"}, nil + } + if err != nil { + return ContentVersion{}, err + } + v := ContentVersion{Mode: uint32(info.Mode().Perm())} + switch { + case info.Mode()&os.ModeSymlink != 0: + target, err := os.Readlink(path) + if err != nil { + return ContentVersion{}, err + } + sum := sha256.Sum256([]byte(target)) + v.Kind, v.Digest = "symlink", hex.EncodeToString(sum[:]) + case info.Mode().IsRegular(): + f, err := os.Open(path) + if err != nil { + return ContentVersion{}, err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, &versionReader{ctx: ctx, r: f}); err != nil { + return ContentVersion{}, err + } + after, err := f.Stat() + if err != nil { + return ContentVersion{}, err + } + if !os.SameFile(info, after) || info.Size() != after.Size() || !info.ModTime().Equal(after.ModTime()) { + return ContentVersion{}, ErrContentChanged + } + v.Kind, v.Digest = "file", hex.EncodeToString(h.Sum(nil)) + case info.IsDir(): + v.Kind = "directory" + default: + return ContentVersion{}, errors.ErrUnsupported + } + return v, nil +} + +type versionReader struct { + ctx context.Context + r io.Reader +} + +func (r *versionReader) Read(p []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.r.Read(p) +} diff --git a/internal/base/fileutil/content_version_test.go b/internal/base/fileutil/content_version_test.go new file mode 100644 index 0000000000..402412e3c0 --- /dev/null +++ b/internal/base/fileutil/content_version_test.go @@ -0,0 +1,70 @@ +package fileutil + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestContentVersionTracksBytesKindAndAbsence(t *testing.T) { + path := filepath.Join(t.TempDir(), "file") + absent, err := ReadContentVersion(t.Context(), path) + if err != nil || absent.Kind != "absent" { + t.Fatalf("absent: %+v %v", absent, err) + } + var versions []ContentVersion + for _, body := range []string{"before", "after!", "before"} { + if err := os.WriteFile(path, []byte(body), 0600); err != nil { + t.Fatal(err) + } + v, err := ReadContentVersion(t.Context(), path) + if err != nil || v.Kind != "file" || v.Digest == "" { + t.Fatalf("file: %+v %v", v, err) + } + versions = append(versions, v) + } + if versions[0] == versions[1] || versions[0] != versions[2] { + t.Fatalf("content identity depends on metadata or misses same-size changes: %+v", versions) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(path, 0700); err != nil { + t.Fatal(err) + } + dir, err := ReadContentVersion(t.Context(), path) + if err != nil || dir.Kind != "directory" || dir.Digest != "" { + t.Fatalf("directory: %+v %v", dir, err) + } +} + +func TestContentVersionCancellationPrecedesFilesystemAccess(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := ReadContentVersion(ctx, filepath.Join(t.TempDir(), "missing")); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled read: %v", err) + } +} + +func TestContentVersionHashesSymlinkTextWithoutReadingTarget(t *testing.T) { + path := filepath.Join(t.TempDir(), "link") + if err := os.Symlink("missing-target", path); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + first, err := ReadContentVersion(t.Context(), path) + if err != nil || first.Kind != "symlink" || first.Digest == "" { + t.Fatalf("link: %+v %v", first, err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Symlink("another-missing-target", path); err != nil { + t.Fatal(err) + } + second, err := ReadContentVersion(t.Context(), path) + if err != nil || second == first { + t.Fatalf("retargeted link: %+v %v", second, err) + } +} diff --git a/internal/state/mutation/callback_failure_test.go b/internal/state/mutation/callback_failure_test.go new file mode 100644 index 0000000000..436ea6fad2 --- /dev/null +++ b/internal/state/mutation/callback_failure_test.go @@ -0,0 +1,123 @@ +package mutation + +import ( + "context" + "errors" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestAdmissionPanicReleasesOnlyItsOwnTransaction(t *testing.T) { + root := t.TempDir() + c, err := New(root, t.TempDir(), nil, Options{AdmissionCheck: func(_ context.Context, intent Intent) error { + if intent.CallID == "panic" { + panic("fixture admission failure") + } + return nil + }}) + if err != nil { + t.Fatal(err) + } + held, err := c.Acquire(t.Context(), Intent{Effect: NamedPaths, Paths: []string{filepath.Join(root, "held")}}) + if err != nil { + t.Fatal(err) + } + defer held.End(NotExecuted) + for index, ctx := range []context.Context{t.Context(), held.Context(t.Context())} { + paths := []string{filepath.Join(root, "held")} + if index == 0 { + paths = []string{filepath.Join(root, "other")} + } + if lease, err := c.Acquire(ctx, Intent{Effect: NamedPaths, Paths: paths, CallID: "panic"}); lease != nil || !errors.Is(err, ErrCallback) { + t.Fatalf("admission panic did not fail closed: %v %v", lease, err) + } + } + if err := held.CheckPaths([]string{filepath.Join(root, "held")}); err != nil { + t.Fatalf("failed child released an existing transaction: %v", err) + } + probe, err := c.Acquire(t.Context(), Intent{Effect: NamedPaths, Paths: []string{filepath.Join(root, "other")}}) + if err != nil { + t.Fatalf("failed admission left resources: %v", err) + } + probe.End(NotExecuted) +} + +func TestNoticePanicClosesWaitAndReleasesTransaction(t *testing.T) { + for _, phase := range []string{"waiting", "acquired", "released"} { + t.Run(phase, func(t *testing.T) { + root := t.TempDir() + waiting := make(chan struct{}) + var waitingOnce sync.Once + var failed atomic.Bool + var mu sync.Mutex + var states []string + c, err := New(root, t.TempDir(), func(notice Notice) { + if notice.CallID != "target" { + return + } + mu.Lock() + states = append(states, notice.State) + mu.Unlock() + if notice.State == "waiting" { + waitingOnce.Do(func() { close(waiting) }) + } + if notice.State == phase && failed.CompareAndSwap(false, true) { + panic("fixture notice failure") + } + }) + if err != nil { + t.Fatal(err) + } + intent := Intent{Effect: NamedPaths, Paths: []string{filepath.Join(root, "shared")}} + held, err := c.Acquire(t.Context(), intent) + if err != nil { + t.Fatal(err) + } + defer held.End(NotExecuted) + type answer struct { + lease *Lease + err error + } + done := make(chan answer, 1) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + go func() { intent.CallID = "target"; lease, err := c.Acquire(ctx, intent); done <- answer{lease, err} }() + select { + case <-waiting: + case <-ctx.Done(): + t.Fatal("wait did not open") + } + held.End(NotExecuted) + result := <-done + if phase == "released" { + if result.err != nil || result.lease == nil { + t.Fatalf("acquire failed: %+v", result) + } + result.lease.End(Committed) + } else if result.lease != nil || !errors.Is(result.err, ErrCallback) { + t.Fatalf("notice failure reached execution: %+v", result) + } + if !failed.Load() { + t.Fatal("fixture did not raise the selected callback failure") + } + mu.Lock() + closed := false + for _, state := range states { + closed = closed || state == "abandoned" || (phase == "released" && state == "acquired") + } + mu.Unlock() + if !closed { + t.Fatal("opened wait never closed") + } + intent.CallID = "probe" + probe, err := c.Acquire(ctx, intent) + if err != nil { + t.Fatalf("callback left live resources: %v", err) + } + probe.End(NotExecuted) + }) + } +} diff --git a/internal/state/mutation/capacity.go b/internal/state/mutation/capacity.go new file mode 100644 index 0000000000..0ee24798a2 --- /dev/null +++ b/internal/state/mutation/capacity.go @@ -0,0 +1,163 @@ +package mutation + +import ( + "cmp" + "context" + "errors" + "path/filepath" + "slices" + "strings" + + "reasonix/internal/base/filelock" + "reasonix/internal/state/workspacelease" +) + +// Capacity is a host-owned execution pool requested atomically with paths. +// A pool ID denotes one immutable limit; changing the limit needs a new ID. +type Capacity struct { + ID string `json:"id"` + Limit uint32 `json:"limit"` + Units uint32 `json:"units"` +} + +func normalizedCapacities(in []Capacity) ([]Capacity, error) { + out := slices.Clone(in) + slices.SortFunc(out, func(a, b Capacity) int { return cmp.Compare(a.ID, b.ID) }) + for i, c := range out { + if strings.TrimSpace(c.ID) == "" || len(c.ID) > 256 || c.Limit == 0 || c.Units == 0 || c.Units > c.Limit || (i > 0 && out[i-1].ID == c.ID) { + return nil, ErrInvalidIntent + } + } + return out, nil +} + +func coversCapacities(held, requested []Capacity) bool { + for _, want := range requested { + if !slices.ContainsFunc(held, func(h Capacity) bool { return h.ID == want.ID && h.Limit == want.Limit && h.Units >= want.Units }) { + return false + } + } + return true +} + +func validRecordResources(r record) bool { + if r.Version == 1 { + return len(r.Paths) > 0 && len(r.Capacities) == 0 && r.Effect != CapacityOnly + } + if r.Version != 2 || len(r.Capacities) == 0 || !filepath.IsAbs(r.Workspace) { + return false + } + if _, err := normalizedCapacities(r.Capacities); err != nil { + return false + } + if r.Effect == CapacityOnly { + return len(r.Paths) == 0 + } + return len(r.Paths) > 0 && (r.Effect == NamedPaths || r.Effect == Workspace || r.Effect == GitMetadata) +} + +func acquisitionContext(ctx context.Context, effect Effect) (context.Context, context.CancelFunc) { + if effect == CapacityOnly { + return context.WithCancel(ctx) + } + return context.WithTimeout(ctx, DefaultWaitLimit) +} + +func capacityLimits(records []record) (map[string]uint64, error) { + limits := map[string]uint64{} + for _, r := range records { + for _, c := range r.Capacities { + if existing, ok := limits[c.ID]; ok && existing != uint64(c.Limit) { + return nil, ErrProtocol + } + limits[c.ID] = uint64(c.Limit) + } + } + return limits, nil +} + +func consumeCapacity(remaining map[string]uint64, capacities []Capacity) bool { + for _, c := range capacities { + if remaining[c.ID] < uint64(c.Units) { + return false + } + } + for _, c := range capacities { + remaining[c.ID] -= uint64(c.Units) + } + return true +} + +func (c *Coordinator) capacityBlocker(r record, records []record) (*record, error) { + remaining, err := capacityLimits(records) + if err != nil { + return nil, err + } + if len(r.Capacities) == 0 { + return nil, nil + } + var claims []record + for _, other := range records { + if other.ID != r.ID && other.Active { + if !consumeCapacity(remaining, other.Capacities) { + return nil, ErrProtocol + } + claims = append(claims, other) + } + } + ordered := slices.Clone(records) + slices.SortFunc(ordered, func(a, b record) int { return cmp.Compare(a.Order, b.Order) }) + for _, other := range ordered { + if other.Active || other.Order >= r.Order || pathBlocker(other, records) != nil { + continue + } + if len(other.Capacities) == 0 { + continue + } + if len(other.Paths) != 0 { + blocked, err := c.compatibilityBlocked(other) + if blocked { + continue + } + if err != nil { + return nil, err + } + } + // Reserve queue priority even when the older request cannot yet fit. + // Otherwise smaller requests can indefinitely consume its next free unit. + for _, capacity := range other.Capacities { + remaining[capacity.ID] -= min(remaining[capacity.ID], uint64(capacity.Units)) + } + claims = append(claims, other) + } + for _, requested := range r.Capacities { + if remaining[requested.ID] >= uint64(requested.Units) { + continue + } + for _, claim := range claims { + if slices.ContainsFunc(claim.Capacities, func(c Capacity) bool { return c.ID == requested.ID }) { + return &claim, nil + } + } + return nil, ErrProtocol + } + return nil, nil +} + +// A preparing transaction may already own its compatibility claim. The OS +// marker distinguishes that reservation from an unrelated legacy blocker. +func (c *Coordinator) compatibilityBlocked(r record) (bool, error) { + release, err := filelock.TryAcquire(c.preparationPath(r.ID)) + if errors.Is(err, filelock.ErrHeld) { + return false, nil + } + if err != nil { + return false, err + } + release() + _, err = workspacelease.ProbeClaimPaths(r.Workspace, c.compatDir, r.Paths) + if errors.Is(err, workspacelease.ErrClaimBusy) { + return true, nil + } + return false, err +} diff --git a/internal/state/mutation/capacity_process_test.go b/internal/state/mutation/capacity_process_test.go new file mode 100644 index 0000000000..1f0447e471 --- /dev/null +++ b/internal/state/mutation/capacity_process_test.go @@ -0,0 +1,41 @@ +package mutation + +import ( + "context" + "errors" + "os" + "os/exec" + "testing" + "time" +) + +func TestCapacityProcessDeathHelper(t *testing.T) { + root := os.Getenv("REASONIX_CAPACITY_DEATH_ROOT") + if root == "" { + return + } + c := testCoordinator(t, root, os.Getenv("REASONIX_CAPACITY_DEATH_STATE")) + if _, err := c.Acquire(t.Context(), Intent{Effect: CapacityOnly, Capacities: []Capacity{capacity("root-total")}}); err != nil { + t.Fatal(err) + } + os.Exit(77) +} + +func TestCapacityReclaimedAfterRealProcessExit(t *testing.T) { + root, state := t.TempDir(), t.TempDir() + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestCapacityProcessDeathHelper$") + cmd.Env = append(os.Environ(), "REASONIX_CAPACITY_DEATH_ROOT="+root, "REASONIX_CAPACITY_DEATH_STATE="+state) + out, err := cmd.CombinedOutput() + var exited *exec.ExitError + if !errors.As(err, &exited) || exited.ExitCode() != 77 { + t.Fatalf("child: %v\n%s", err, out) + } + c := testCoordinator(t, root, state) + l, err := c.Acquire(ctx, Intent{Effect: CapacityOnly, Capacities: []Capacity{capacity("root-total")}}) + if err != nil { + t.Fatalf("dead process retained capacity: %v", err) + } + l.End(NotExecuted) +} diff --git a/internal/state/mutation/capacity_scope_test.go b/internal/state/mutation/capacity_scope_test.go new file mode 100644 index 0000000000..6ad4bffbe6 --- /dev/null +++ b/internal/state/mutation/capacity_scope_test.go @@ -0,0 +1,56 @@ +package mutation + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" +) + +func TestCapacityLeaseCannotAuthorizeFilesystemWrites(t *testing.T) { + c := testCoordinator(t, t.TempDir(), t.TempDir()) + l, err := c.Acquire(t.Context(), Intent{Effect: CapacityOnly, Capacities: []Capacity{capacity("total")}}) + if err != nil { + t.Fatal(err) + } + defer l.End(NotExecuted) + ctx := l.Context(t.Context()) + if l.HoldingPaths() { + t.Fatal("capacity-only admission impersonated a file mutation lease") + } + if _, err := c.Acquire(ctx, intent(filepath.Join(c.root, "a"))); !errors.Is(err, ErrInvalidIntent) { + t.Fatalf("file upgrade allowed: %v", err) + } + if _, err := c.Acquire(ctx, Intent{Effect: CapacityOnly, Capacities: []Capacity{capacity("other")}}); !errors.Is(err, ErrInvalidIntent) { + t.Fatalf("capacity upgrade allowed: %v", err) + } + child, err := c.Acquire(ctx, Intent{Effect: CapacityOnly, Capacities: []Capacity{capacity("total")}}) + if err != nil { + t.Fatal(err) + } + child.End(NotExecuted) + blocked, cancel := context.WithTimeout(t.Context(), 40*time.Millisecond) + defer cancel() + _, err = c.Acquire(blocked, Intent{Effect: CapacityOnly, Capacities: []Capacity{capacity("total")}}) + if !errors.Is(err, ErrCapacityWaitTimeout) || errors.Is(err, ErrWaitTimeout) { + t.Fatalf("incorrect capacity timeout: %v", err) + } +} + +func TestCapacityRejectsAmbiguousOrInvalidRequests(t *testing.T) { + c := testCoordinator(t, t.TempDir(), t.TempDir()) + for _, request := range []Intent{ + {Effect: CapacityOnly}, + {Effect: CapacityOnly, Paths: []string{"a"}, Capacities: []Capacity{capacity("total")}}, + {Effect: ReadOnly, Capacities: []Capacity{capacity("total")}}, + {Effect: External, Capacities: []Capacity{capacity("total")}}, + {Effect: CapacityOnly, Capacities: []Capacity{capacity("total"), capacity("total")}}, + {Effect: CapacityOnly, Capacities: []Capacity{{ID: "total", Limit: 1, Units: 2}}}, + {Effect: CapacityOnly, Capacities: []Capacity{{ID: "total", Units: 1}}}, + } { + if _, err := c.Acquire(t.Context(), request); !errors.Is(err, ErrInvalidIntent) { + t.Fatalf("request %+v: %v", request, err) + } + } +} diff --git a/internal/state/mutation/capacity_test.go b/internal/state/mutation/capacity_test.go new file mode 100644 index 0000000000..10e6f363da --- /dev/null +++ b/internal/state/mutation/capacity_test.go @@ -0,0 +1,330 @@ +package mutation + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "reasonix/internal/state/workspacelease" +) + +func capacity(id string) Capacity { return Capacity{ID: id, Limit: 1, Units: 1} } + +func TestDisjointWriterCapacityWaitReportsActualBlockingResource(t *testing.T) { + root, state := t.TempDir(), t.TempDir() + c := testCoordinator(t, root, state) + first := intent(filepath.Join(root, "first")) + first.Capacities = []Capacity{capacity("writers")} + held, err := c.Acquire(t.Context(), first) + if err != nil { + t.Fatal(err) + } + defer held.End(NotExecuted) + var notices []Notice + waiter, err := New(root, state, func(n Notice) { + if n.State != "released" { + notices = append(notices, n) + } + if n.State == "waiting" { + held.End(NotExecuted) + } + }) + if err != nil { + t.Fatal(err) + } + second := intent(filepath.Join(root, "second")) + second.Capacities = first.Capacities + lease, err := waiter.Acquire(t.Context(), second) + if err != nil { + t.Fatal(err) + } + lease.End(NotExecuted) + if len(notices) != 2 { + t.Fatalf("unpaired wait: %+v", notices) + } + for _, n := range notices { + if n.Effect != NamedPaths || n.BlockedOn != CapacityOnly { + t.Fatalf("request kind confused with blocking cause: %+v", n) + } + } +} + +func awaitCapacityNotice(t *testing.T, notices <-chan Notice, state string) Notice { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + for { + select { + case n := <-notices: + if n.State == state { + return n + } + case <-ctx.Done(): + t.Fatalf("missing %s notice", state) + } + } +} + +func TestWaitingFileTransactionDoesNotConsumeCapacity(t *testing.T) { + root, state := t.TempDir(), t.TempDir() + owner := testCoordinator(t, root, state) + path := filepath.Join(root, "a") + held, err := owner.Acquire(t.Context(), intent(path)) + if err != nil { + t.Fatal(err) + } + defer held.End(NotExecuted) + notices := make(chan Notice, 16) + waiter, err := New(root, state, func(n Notice) { notices <- n }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan error, 1) + go func() { + request := intent(path) + request.Capacities = []Capacity{capacity("root-total")} + lease, err := waiter.Acquire(ctx, request) + if lease != nil { + lease.End(NotExecuted) + } + done <- err + }() + awaitCapacityNotice(t, notices, "waiting") + model, err := owner.Acquire(t.Context(), Intent{Effect: CapacityOnly, Capacities: []Capacity{capacity("root-total")}}) + if err != nil { + t.Fatalf("waiting writer consumed model capacity: %v", err) + } + defer model.End(NotExecuted) + held.End(NotExecuted) + model.End(NotExecuted) + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("writer did not resume") + } +} + +func TestWaitingCapacityTransactionDoesNotHoldPaths(t *testing.T) { + root, state := t.TempDir(), t.TempDir() + owner := testCoordinator(t, root, state) + held, err := owner.Acquire(t.Context(), Intent{Effect: CapacityOnly, Capacities: []Capacity{capacity("root-total")}}) + if err != nil { + t.Fatal(err) + } + defer held.End(NotExecuted) + notices := make(chan Notice, 16) + waiter, err := New(root, state, func(n Notice) { notices <- n }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan error, 1) + path := filepath.Join(root, "a") + go func() { + request := intent(path) + request.Capacities = []Capacity{capacity("root-total")} + lease, err := waiter.Acquire(ctx, request) + if lease != nil { + lease.End(NotExecuted) + } + done <- err + }() + awaitCapacityNotice(t, notices, "waiting") + // An unrelated writer has no admission to this root's execution budget. + other, err := testCoordinator(t, t.TempDir(), state).Acquire(t.Context(), intent(filepath.Join(root, "b"))) + if err != nil { + t.Fatal(err) + } + other.End(NotExecuted) + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + lease, err := owner.Acquire(t.Context(), intent(path)) + if err != nil { + t.Fatalf("cancelled capacity request retained paths: %v", err) + } + lease.End(NotExecuted) +} + +func TestCapacityGrantsReadyWaitersInOrder(t *testing.T) { + root, state := t.TempDir(), t.TempDir() + owner := testCoordinator(t, root, state) + held, err := owner.Acquire(t.Context(), Intent{Effect: CapacityOnly, Capacities: []Capacity{capacity("total")}}) + if err != nil { + t.Fatal(err) + } + defer held.End(NotExecuted) + granted := make(chan string, 2) + releases := []chan struct{}{make(chan struct{}), make(chan struct{})} + done := make(chan error, 2) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + for index, id := range []string{"first", "second"} { + notices := make(chan Notice, 16) + c, err := New(root, state, func(n Notice) { notices <- n }) + if err != nil { + t.Fatal(err) + } + go func() { + l, err := c.Acquire(ctx, Intent{Effect: CapacityOnly, ExecutionID: id, Capacities: []Capacity{capacity("total")}}) + if err == nil { + granted <- id + select { + case <-releases[index]: + case <-ctx.Done(): + } + l.End(NotExecuted) + } + done <- err + }() + awaitCapacityNotice(t, notices, "waiting") + } + held.End(NotExecuted) + select { + case id := <-granted: + if id != "first" { + t.Fatal(id) + } + case <-time.After(5 * time.Second): + t.Fatal("first waiter stuck") + } + close(releases[0]) + select { + case id := <-granted: + if id != "second" { + t.Fatal(id) + } + case <-time.After(5 * time.Second): + t.Fatal("second waiter stuck") + } + close(releases[1]) + for range 2 { + if err := <-done; err != nil { + t.Fatal(err) + } + } +} + +func TestCapacityIdentityCannotChangeLimit(t *testing.T) { + c := testCoordinator(t, t.TempDir(), t.TempDir()) + l, err := c.Acquire(t.Context(), Intent{Effect: CapacityOnly, Capacities: []Capacity{capacity("total")}}) + if err != nil { + t.Fatal(err) + } + defer l.End(NotExecuted) + _, err = c.Acquire(t.Context(), Intent{Effect: CapacityOnly, Capacities: []Capacity{{ID: "total", Limit: 2, Units: 1}}}) + if !errors.Is(err, ErrProtocol) { + t.Fatalf("capacity limit changed under an active owner: %v", err) + } +} + +func TestLegacyWaitDoesNotConsumeExecutionCapacity(t *testing.T) { + root, state := t.TempDir(), t.TempDir() + path := filepath.Join(root, "a") + legacy, err := workspacelease.New(root, state, nil) + if err != nil { + t.Fatal(err) + } + legacy.BeginRun() + defer legacy.EndRun() + if err := legacy.AcquirePaths(t.Context(), []string{path}); err != nil { + t.Fatal(err) + } + notices := make(chan Notice, 16) + c, err := New(root, state, func(n Notice) { notices <- n }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + defer func() { cancel(); <-done }() + go func() { + req := intent(path) + req.Capacities = []Capacity{capacity("total")} + l, err := c.Acquire(ctx, req) + if l != nil { + l.End(NotExecuted) + } + done <- err + }() + awaitCapacityNotice(t, notices, "waiting") + probeCtx, stop := context.WithTimeout(t.Context(), time.Second) + defer stop() + l, err := testCoordinator(t, root, state).Acquire(probeCtx, Intent{Effect: CapacityOnly, Capacities: []Capacity{capacity("total")}}) + if err != nil { + t.Fatalf("legacy file wait exhausted execution capacity: %v", err) + } + l.End(NotExecuted) +} + +func TestLargeCapacityRequestCannotBeStarvedBySmallerLaterRequests(t *testing.T) { + root, state := t.TempDir(), t.TempDir() + one := Intent{Effect: CapacityOnly, Capacities: []Capacity{{ID: "total", Limit: 2, Units: 1}}} + owner := testCoordinator(t, root, state) + held, err := owner.Acquire(t.Context(), one) + if err != nil { + t.Fatal(err) + } + defer held.End(NotExecuted) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + type result struct { + lease *Lease + err error + } + start := func(request Intent) (<-chan Notice, <-chan result) { + notices, done := make(chan Notice, 16), make(chan result, 1) + c, err := New(root, state, func(n Notice) { notices <- n }) + if err != nil { + t.Fatal(err) + } + go func() { l, err := c.Acquire(ctx, request); done <- result{l, err} }() + return notices, done + } + largeNotices, largeDone := start(Intent{Effect: CapacityOnly, Capacities: []Capacity{{ID: "total", Limit: 2, Units: 2}}}) + awaitCapacityNotice(t, largeNotices, "waiting") + smallNotices, smallDone := start(one) + select { + case n := <-smallNotices: + if n.State != "waiting" { + t.Fatalf("unexpected event: %+v", n) + } + case got := <-smallDone: + if got.lease != nil { + got.lease.End(NotExecuted) + } + t.Fatalf("small request bypassed earlier large request: %v", got.err) + case <-ctx.Done(): + t.Fatal("small request did not queue") + } + held.End(NotExecuted) + var large result + select { + case large = <-largeDone: + case <-ctx.Done(): + t.Fatal("large request starved") + } + if large.err != nil { + t.Fatal(large.err) + } + defer large.lease.End(NotExecuted) + large.lease.End(NotExecuted) + select { + case small := <-smallDone: + if small.err != nil { + t.Fatal(small.err) + } + small.lease.End(NotExecuted) + case <-ctx.Done(): + t.Fatal("small request did not resume") + } +} diff --git a/internal/state/mutation/case_fold_test.go b/internal/state/mutation/case_fold_test.go new file mode 100644 index 0000000000..91df1ef758 --- /dev/null +++ b/internal/state/mutation/case_fold_test.go @@ -0,0 +1,39 @@ +package mutation + +import ( + "os" + "path/filepath" + "testing" +) + +func TestHeldPathsCoverOnlyTheSameFileAcrossCaseSpellings(t *testing.T) { + root := t.TempDir() + c, err := New(root, t.TempDir(), nil) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "existing.go"), nil, 0600); err != nil { + t.Fatal(err) + } + lease, err := c.Acquire(t.Context(), Intent{Effect: NamedPaths, Paths: []string{"absent.go", "existing.go"}}) + if err != nil { + t.Fatal(err) + } + defer lease.End(NotExecuted) + if err := lease.CheckPaths([]string{"ABSENT.go"}); err == nil { + t.Fatal("a different spelling of an absent file was treated as held") + } + other := filepath.Join(root, "EXISTING.go") + if info, statErr := os.Stat(other); statErr == nil { + held, _ := os.Stat(filepath.Join(root, "existing.go")) + if os.SameFile(info, held) { + if err := lease.CheckPaths([]string{"EXISTING.go"}); err != nil { + t.Fatalf("the same file under another spelling was refused: %v", err) + } + return + } + } + if err := lease.CheckPaths([]string{"EXISTING.go"}); err == nil { + t.Fatal("a distinct file was treated as held") + } +} diff --git a/internal/state/mutation/compatibility_phase_test.go b/internal/state/mutation/compatibility_phase_test.go new file mode 100644 index 0000000000..72f1c9caa9 --- /dev/null +++ b/internal/state/mutation/compatibility_phase_test.go @@ -0,0 +1,131 @@ +package mutation + +import ( + "context" + "errors" + "path/filepath" + "slices" + "testing" + + "reasonix/internal/base/filelock" + "reasonix/internal/state/workspacelease" +) + +func TestCompatibilityPreparationDoesNotHoldTheGlobalRegistry(t *testing.T) { + root, state := t.TempDir(), t.TempDir() + first := testCoordinator(t, root, state) + a, b := filepath.Join(root, "a"), filepath.Join(root, "b") + entered, resume := make(chan struct{}), make(chan struct{}) + first.compatClaim = func(root, dir, session string, paths []string) (func(), *workspacelease.ConflictError, error) { + if slices.ContainsFunc(paths, func(path string) bool { return filepath.Base(path) == "a" }) { + close(entered) + <-resume + } + return workspacelease.TryClaimPaths(root, dir, session, paths) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan error, 1) + go func() { + lease, err := first.Acquire(ctx, intent(a)) + if lease != nil { + lease.End(NotExecuted) + } + done <- err + }() + select { + case <-entered: + case err := <-done: + t.Fatalf("call returned before compatibility preparation: %v", err) + } + defer func() { close(resume); <-done }() + unlock, err := filelock.TryAcquire(first.guardPath()) + if err != nil { + t.Fatalf("compatibility discovery retained the global registry: %v", err) + } + unlock() + second := testCoordinator(t, root, state) + lease, err := second.Acquire(t.Context(), intent(b)) + if err != nil { + t.Fatalf("unrelated resources blocked by compatibility preparation: %v", err) + } + lease.End(NotExecuted) +} + +func TestPreparingCompatibilityClaimRetainsCapacityQueuePriority(t *testing.T) { + root, state := t.TempDir(), t.TempDir() + first := testCoordinator(t, root, state) + claimed, resume := make(chan struct{}), make(chan struct{}) + first.compatClaim = func(root, dir, session string, paths []string) (func(), *workspacelease.ConflictError, error) { + release, conflict, err := workspacelease.TryClaimPaths(root, dir, session, paths) + if err == nil { + close(claimed) + <-resume + } + return release, conflict, err + } + pool := []Capacity{{ID: "writers", Limit: 1, Units: 1}} + want := intent(filepath.Join(root, "a")) + want.Capacities = pool + done := make(chan error, 1) + go func() { + lease, err := first.Acquire(t.Context(), want) + if lease != nil { + lease.End(NotExecuted) + } + done <- err + }() + select { + case <-claimed: + case err := <-done: + t.Fatalf("first call returned before compatibility grant: %v", err) + } + defer func() { + close(resume) + if err := <-done; err != nil { + t.Errorf("first call lost admission: %v", err) + } + }() + second := testCoordinator(t, root, state) + want = intent(filepath.Join(root, "b")) + want.Capacities = pool + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + second.notify = func(notice Notice) { + if notice.State == "waiting" { + cancel() + } + } + lease, err := second.Acquire(ctx, want) + if lease != nil { + lease.End(NotExecuted) + t.Fatal("later disjoint path stole the preparing transaction's capacity") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("later capacity waiter: %v", err) + } +} + +func TestCancellationAfterCompatibilityGrantLeavesNoClaims(t *testing.T) { + root, state := t.TempDir(), t.TempDir() + c := testCoordinator(t, root, state) + path := filepath.Join(root, "a") + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + c.compatClaim = func(root, dir, session string, paths []string) (func(), *workspacelease.ConflictError, error) { + release, conflict, err := workspacelease.TryClaimPaths(root, dir, session, paths) + if err == nil { + cancel() + } + return release, conflict, err + } + if _, err := c.Acquire(ctx, intent(path)); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled compatibility admission: %v", err) + } + second := testCoordinator(t, root, state) + lease, err := second.Acquire(t.Context(), intent(path)) + if err != nil { + t.Fatalf("cancelled admission retained new claims: %v", err) + } + lease.End(NotExecuted) +} diff --git a/internal/state/mutation/compatibility_test.go b/internal/state/mutation/compatibility_test.go new file mode 100644 index 0000000000..20e888a531 --- /dev/null +++ b/internal/state/mutation/compatibility_test.go @@ -0,0 +1,27 @@ +package mutation + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestUnknownOrCorruptCoordinationRecordIsPreserved(t *testing.T) { + for _, raw := range []string{`{"version":2,"id":"future","order":1,"paths":["/future"]}`, `{"version":`} { + t.Run(raw, func(t *testing.T) { + root, state := t.TempDir(), t.TempDir() + c := testCoordinator(t, root, state) + path := c.recordPath("future") + if err := os.WriteFile(path, []byte(raw), 0600); err != nil { + t.Fatal(err) + } + if _, err := c.Acquire(t.Context(), intent(filepath.Join(root, "file"))); !errors.Is(err, ErrProtocol) { + t.Fatalf("acquire: %v", err) + } + if got, err := os.ReadFile(path); err != nil || string(got) != raw { + t.Fatalf("unknown record modified: %q %v", got, err) + } + }) + } +} diff --git a/internal/state/mutation/completion.go b/internal/state/mutation/completion.go new file mode 100644 index 0000000000..5eca07ffb4 --- /dev/null +++ b/internal/state/mutation/completion.go @@ -0,0 +1,86 @@ +package mutation + +import ( + "context" + "log/slog" + "os" + "sync" + "time" + + "reasonix/internal/base/filelock" +) + +type leaseCompletion struct { + mu sync.Mutex + waits []<-chan struct{} +} + +func HoldUntil(ctx context.Context, done <-chan struct{}) error { + l, _ := ctx.Value(leaseContextKey{}).(*Lease) + return l.HoldUntil(done) +} + +// HoldUntil retains physical ownership after End until execution is settled. +func (l *Lease) HoldUntil(done <-chan struct{}) error { + if l == nil || l.coordinator == nil || done == nil { + return nil + } + l.completion.mu.Lock() + defer l.completion.mu.Unlock() + if l.closed.Load() { + return ErrInvalidIntent + } + l.completion.waits = append(l.completion.waits, done) + return nil +} + +func (l *Lease) endAfterCompletion(outcome Outcome) { + l.completion.mu.Lock() + waits := l.completion.waits + l.completion.waits = nil + l.completion.mu.Unlock() + for i, done := range waits { + select { + case <-done: + default: + go func() { + for _, done := range waits[i:] { + <-done + } + l.releaseResources(outcome) + }() + return + } + } + l.releaseResources(outcome) +} + +func (l *Lease) releaseResources(outcome Outcome) { + if l.coordinator == nil { + return + } + c := l.coordinator + if l.releaseCompat != nil { + l.releaseCompat() + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if unlock, err := filelock.Acquire(ctx, c.guardPath()); err == nil { + _ = os.Remove(c.recordPath(l.ID)) + if l.releaseLive != nil { + l.releaseLive() + l.releaseLive = nil + } + _ = os.Remove(c.livePath(l.ID)) + _ = os.Remove(c.preparationPath(l.ID)) + unlock() + } + if l.releaseLive != nil { + l.releaseLive() + } + if !l.acquiredAt.IsZero() { + if err := c.notice(l.record, "released", nil, outcome); err != nil { + slog.Warn("mutation release notification failed after resource release", "transaction", l.ID, "err", err) + } + } +} diff --git a/internal/state/mutation/completion_test.go b/internal/state/mutation/completion_test.go new file mode 100644 index 0000000000..a583cb760c --- /dev/null +++ b/internal/state/mutation/completion_test.go @@ -0,0 +1,55 @@ +package mutation + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestEndedTransactionRetainsResourcesUntilAllExecutionsSettle(t *testing.T) { + root, state := t.TempDir(), t.TempDir() + first, second := testCoordinator(t, root, state), testCoordinator(t, root, state) + l, err := first.Acquire(t.Context(), Intent{Effect: Workspace}) + if err != nil { + t.Fatal(err) + } + a, b := make(chan struct{}), make(chan struct{}) + if err := HoldUntil(l.Context(t.Context()), a); err != nil { + t.Fatal(err) + } + if err := l.HoldUntil(b); err != nil { + t.Fatal(err) + } + l.End(Unknown) + if l.HoldingPaths() || l.CheckPaths([]string{root}) == nil { + t.Fatal("ended caller retained execution authority") + } + if err := l.HoldUntil(make(chan struct{})); !errors.Is(err, ErrInvalidIntent) { + t.Fatalf("ended caller registered new execution: %v", err) + } + assertBusy := func() { + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Millisecond) + defer cancel() + other, err := second.Acquire(ctx, Intent{Effect: Workspace}) + if other != nil { + other.End(NotExecuted) + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("unsettled execution lost exclusion: %v", err) + } + } + assertBusy() + close(a) + assertBusy() + close(b) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + next, err := second.Acquire(ctx, Intent{Effect: Workspace}) + if err != nil { + t.Fatal(err) + } + defer next.End(NotExecuted) + l.End(NotExecuted) + assertBusy() +} diff --git a/internal/state/mutation/continuation.go b/internal/state/mutation/continuation.go new file mode 100644 index 0000000000..470a09cf5c --- /dev/null +++ b/internal/state/mutation/continuation.go @@ -0,0 +1,37 @@ +package mutation + +import ( + "context" + "slices" +) + +// Continuation admits asynchronous work as a new transaction after its caller +// releases the current one. It carries resource identity, never a live lease. +type Continuation struct { + coordinator *Coordinator + intent Intent +} + +func (l *Lease) PrepareContinuation() (*Continuation, error) { + if l == nil || l.coordinator == nil { + return nil, nil + } + if l.closed.Load() { + return nil, ErrInvalidIntent + } + effect := l.record.Effect + if effect == "" { + effect = NamedPaths + } + return &Continuation{coordinator: l.coordinator, intent: Intent{Effect: effect, + Capacities: slices.Clone(l.record.Capacities), + Paths: slices.Clone(l.record.Paths), SessionID: l.record.SessionID, + ExecutionID: l.record.ExecutionID, CallID: l.record.CallID}}, nil +} + +func (c *Continuation) Acquire(ctx context.Context) (*Lease, error) { + if c == nil { + return nil, nil + } + return c.coordinator.Acquire(WithoutLease(ctx), c.intent) +} diff --git a/internal/state/mutation/coordinator.go b/internal/state/mutation/coordinator.go new file mode 100644 index 0000000000..75d5637554 --- /dev/null +++ b/internal/state/mutation/coordinator.go @@ -0,0 +1,280 @@ +package mutation + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "sync" + "sync/atomic" + "time" + + "reasonix/internal/base/filelock" + "reasonix/internal/base/fileutil" + "reasonix/internal/state/workspacelease" +) + +const DefaultWaitLimit = 30 * time.Second +const retryInterval = 20 * time.Millisecond + +type Coordinator struct { + root string + dir string + compatDir string + notify func(Notice) + admit func(context.Context, Intent) error + workspaceResources []string + compatClaim func(string, string, string, []string) (func(), *workspacelease.ConflictError, error) +} + +type Options struct { + AdmissionCheck func(context.Context, Intent) error + WorkspaceResources []string +} + +func New(root, stateDir string, notify func(Notice), opts ...Options) (*Coordinator, error) { + canonical, err := fileutil.CanonicalWritePath(root) + if err != nil { + return nil, err + } + if stateDir == "" { + return nil, ErrInvalidIntent + } + dir := filepath.Join(stateDir, "mutations-v1") + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, err + } + c := &Coordinator{root: canonical, dir: dir, compatDir: stateDir, notify: notify, compatClaim: workspacelease.TryClaimPaths} + for _, opt := range opts { + c.admit = opt.AdmissionCheck + c.workspaceResources = slices.Clone(opt.WorkspaceResources) + } + return c, nil +} + +type Lease struct { + ID string + once sync.Once + coordinator *Coordinator + record record + releaseLive func() + releaseCompat func() + acquiredAt time.Time + closed atomic.Bool + completion leaseCompletion +} + +// TryAcquire preserves queue order but never waits for a conflicting resource. +// Registry operations remain cancellable short critical sections. +func (c *Coordinator) TryAcquire(ctx context.Context, intent Intent) (*Lease, error) { + intent.tryOnly = true + return c.Acquire(ctx, intent) +} + +func (c *Coordinator) Acquire(ctx context.Context, intent Intent) (acquired *Lease, resultErr error) { + var owned *Lease + waiting := false + defer func() { + if p := recover(); p != nil { + acquired, resultErr = nil, fmt.Errorf("%w: admission panicked: %v", ErrCallback, p) + } + resultErr = c.abandonAcquisition(owned, acquired, waiting, resultErr) + }() + if err := ctx.Err(); err != nil { + return nil, err + } + capacities, err := normalizedCapacities(intent.Capacities) + if err != nil { + return nil, err + } + intent.Capacities = capacities + paths, err := c.resources(intent) + if err != nil { + return nil, err + } + if len(paths) == 0 && len(capacities) == 0 { + return &Lease{}, nil + } + if parent, _ := ctx.Value(leaseContextKey{}).(*Lease); parent != nil && parent.coordinator == c { + if parent.closed.Load() || !covers(parent.record.Paths, paths) || !coversCapacities(parent.record.Capacities, capacities) { + return nil, ErrInvalidIntent + } + if err := c.validate(ctx, intent, paths); err != nil { + return nil, err + } + return &Lease{}, nil + } + ctx, cancel := acquisitionContext(ctx, intent.Effect) + defer cancel() + l := &Lease{ID: rand.Text(), coordinator: c, record: record{ + Version: 1, SessionID: intent.SessionID, ExecutionID: intent.ExecutionID, + CallID: intent.CallID, Paths: paths, StartedAt: time.Now(), + Effect: intent.Effect, Capacities: capacities, + Workspace: c.root, + }} + if len(capacities) != 0 { + l.record.Version = 2 + } + l.record.ID = l.ID + defer func() { + if errors.Is(resultErr, context.DeadlineExceeded) { + resultErr = &WaitError{TransactionID: l.ID, Paths: paths, Cause: resultErr, Effect: intent.Effect, Capacities: capacities} + } + }() + l.releaseLive, err = filelock.TryAcquire(c.livePath(l.ID)) + if err != nil { + return nil, err + } + owned = l + if err := c.enqueue(ctx, &l.record); err != nil { + return nil, err + } + probe := versionProbe{} + for { + if err := ctx.Err(); err != nil { + return nil, err + } + if err := probe.check(ctx, intent.Expected, paths); err != nil { + return nil, err + } + held, err := c.tryGrant(ctx, l) + if err != nil { + return nil, err + } + if held == nil { + return c.finishAcquisition(ctx, l, intent, paths, waiting) + } + if intent.tryOnly { + return nil, ErrBusy + } + if !waiting { + waiting = true + l.record.waitKind = held.kind + if err := c.notice(l.record, "waiting", &held.record, NotExecuted); err != nil { + return nil, err + } + } + timer := time.NewTimer(retryInterval) + select { + case <-ctx.Done(): + timer.Stop() + case <-timer.C: + } + } +} + +func (c *Coordinator) finishAcquisition(ctx context.Context, lease *Lease, intent Intent, paths []string, waiting bool) (*Lease, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if err := c.validate(ctx, intent, paths); err != nil { + return nil, err + } + lease.acquiredAt = time.Now() + if waiting { + if err := c.notice(lease.record, "acquired", nil, NotExecuted); err != nil { + return nil, err + } + } + return lease, nil +} + +func (c *Coordinator) abandonAcquisition(owned, acquired *Lease, waiting bool, err error) error { + if owned == nil || acquired != nil { + return err + } + owned.End(NotExecuted) + if waiting { + return errors.Join(err, c.waitEnded(owned.record, err)) + } + return err +} + +func (l *Lease) End(outcome Outcome) { + if l == nil { + return + } + l.once.Do(func() { + l.closed.Store(true) + l.endAfterCompletion(outcome) + }) +} + +type leaseContextKey struct{} + +func (l *Lease) HoldingPaths() bool { + return l != nil && l.coordinator != nil && len(l.record.Paths) != 0 && !l.closed.Load() +} + +func (l *Lease) CheckPaths(paths []string) error { + if l == nil || l.coordinator == nil || l.closed.Load() { + return ErrInvalidIntent + } + requested, err := l.coordinator.resources(Intent{Effect: NamedPaths, Paths: paths}) + if err != nil { + return err + } + if !covers(l.record.Paths, requested) { + return ErrInvalidIntent + } + return nil +} + +func (l *Lease) Context(ctx context.Context) context.Context { + if l == nil || l.coordinator == nil { + return ctx + } + return context.WithValue(ctx, leaseContextKey{}, l) +} + +func WithoutLease(ctx context.Context) context.Context { + return context.WithValue(ctx, leaseContextKey{}, (*Lease)(nil)) +} + +func (c *Coordinator) notice(r record, state string, holder *record, outcome Outcome) error { + return c.emitNotice(r, state, "", holder, outcome) +} + +func (c *Coordinator) emitNotice(r record, state, reason string, holder *record, outcome Outcome) (resultErr error) { + defer func() { + if p := recover(); p != nil { + resultErr = fmt.Errorf("%w: notice %s panicked: %v", ErrCallback, state, p) + } + }() + if c.notify == nil { + return nil + } + n := Notice{TransactionID: r.ID, SessionID: r.SessionID, ExecutionID: r.ExecutionID, + Effect: r.Effect, BlockedOn: r.waitKind, Capacities: slices.Clone(r.Capacities), + CallID: r.CallID, Paths: slices.Clone(r.Paths), State: state, StartedAt: r.StartedAt, + Elapsed: time.Since(r.StartedAt), Outcome: outcome, Reason: reason} + if holder != nil { + n.HolderSessionID, n.HolderExecutionID = holder.SessionID, holder.ExecutionID + n.HolderTransactionID, n.HolderPaths = holder.ID, slices.Clone(holder.Paths) + } + c.notify(n) + return nil +} + +func (c *Coordinator) waitEnded(r record, err error) error { + reason := "rejected" + switch { + case errors.Is(err, context.Canceled): + reason = "cancelled" + case errors.Is(err, context.DeadlineExceeded): + reason = "timeout" + case errors.Is(err, ErrVersionConflict): + reason = "conflict" + } + return c.emitNotice(r, "abandoned", reason, nil, NotExecuted) +} + +func (c *Coordinator) recordPath(id string) string { return filepath.Join(c.dir, id+".json") } +func (c *Coordinator) livePath(id string) string { return filepath.Join(c.dir, id+".live") } +func (c *Coordinator) guardPath() string { return filepath.Join(c.dir, "registry.lock") } +func (c *Coordinator) preparationPath(id string) string { + return filepath.Join(c.dir, id+".preparing") +} diff --git a/internal/state/mutation/coordinator_test.go b/internal/state/mutation/coordinator_test.go new file mode 100644 index 0000000000..a1e8012756 --- /dev/null +++ b/internal/state/mutation/coordinator_test.go @@ -0,0 +1,172 @@ +package mutation + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func testCoordinator(t *testing.T, root, state string) *Coordinator { + t.Helper() + c, err := New(root, state, nil) + if err != nil { + t.Fatal(err) + } + return c +} + +func intent(paths ...string) Intent { + return Intent{Effect: NamedPaths, Paths: paths, SessionID: "test", ExecutionID: "execution", CallID: "call"} +} + +func TestTransactionReleasesBeforeSessionEnds(t *testing.T) { + root, state := t.TempDir(), t.TempDir() + a, b := testCoordinator(t, root, state), testCoordinator(t, root, state) + first, err := a.Acquire(t.Context(), intent(filepath.Join(root, "a"))) + if err != nil { + t.Fatal(err) + } + second, err := b.Acquire(t.Context(), intent(filepath.Join(root, "b"))) + if err != nil { + t.Fatal(err) + } + second.End(NotExecuted) + ctx, cancel := context.WithTimeout(t.Context(), 40*time.Millisecond) + defer cancel() + if _, err := b.Acquire(ctx, intent(filepath.Join(root, "a"))); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("overlap: %v", err) + } + first.End(Committed) + next, err := b.Acquire(t.Context(), intent(filepath.Join(root, "a"))) + if err != nil { + t.Fatal(err) + } + defer next.End(NotExecuted) + first.End(NotExecuted) + ctx2, cancel2 := context.WithTimeout(t.Context(), 40*time.Millisecond) + defer cancel2() + if _, err := a.Acquire(ctx2, intent(filepath.Join(root, "a"))); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("old release removed new lease: %v", err) + } +} + +func TestDifferentWorkspacesCoordinateExtraPathAndHardlinks(t *testing.T) { + state, extra := t.TempDir(), filepath.Join(t.TempDir(), "shared") + if err := os.WriteFile(extra, []byte("base"), 0600); err != nil { + t.Fatal(err) + } + a, b := testCoordinator(t, t.TempDir(), state), testCoordinator(t, t.TempDir(), state) + lease, err := a.Acquire(t.Context(), intent(extra)) + if err != nil { + t.Fatal(err) + } + defer lease.End(NotExecuted) + alias := filepath.Join(t.TempDir(), "alias") + if err := os.Link(extra, alias); err != nil { + t.Skipf("hardlinks unavailable: %v", err) + } + for _, path := range []string{extra, alias} { + ctx, cancel := context.WithTimeout(t.Context(), 40*time.Millisecond) + _, err := b.Acquire(ctx, intent(path)) + cancel() + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("%s: %v", path, err) + } + } +} + +func TestQueuedSetDoesNotHoldPartialResources(t *testing.T) { + root, state := t.TempDir(), t.TempDir() + c := testCoordinator(t, root, state) + a, b := filepath.Join(root, "a"), filepath.Join(root, "b") + first, err := c.Acquire(t.Context(), intent(a)) + if err != nil { + t.Fatal(err) + } + defer first.End(NotExecuted) + queued := make(chan Notice, 8) + waiter, err := New(root, state, func(n Notice) { queued <- n }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { + l, e := waiter.Acquire(ctx, intent(a, b)) + if l != nil { + l.End(NotExecuted) + } + done <- e + }() + select { + case <-queued: + case <-time.After(3 * time.Second): + t.Fatal("wait was not reported") + } + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + last, err := c.Acquire(t.Context(), intent(b)) + if err != nil { + t.Fatal(err) + } + last.End(NotExecuted) +} + +func TestExplicitEffectsAndContentPreconditions(t *testing.T) { + root, state := t.TempDir(), t.TempDir() + c := testCoordinator(t, root, state) + if _, err := c.Acquire(t.Context(), intent()); !errors.Is(err, ErrInvalidIntent) { + t.Fatalf("empty named set: %v", err) + } + path := filepath.Join(root, "a") + if err := os.WriteFile(path, []byte("before"), 0600); err != nil { + t.Fatal(err) + } + version, err := ReadVersionContext(t.Context(), path) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("after"), 0600); err != nil { + t.Fatal(err) + } + i := intent(path) + i.Expected = map[string]ContentVersion{path: version} + if _, err := c.Acquire(t.Context(), i); !errors.Is(err, ErrVersionConflict) { + t.Fatalf("stale precondition: %v", err) + } + lease, err := c.Acquire(t.Context(), intent(path)) + if err != nil { + t.Fatalf("failed precondition leaked resources: %v", err) + } + lease.End(NotExecuted) +} + +func TestNestedTransactionCannotWidenOrReleaseParent(t *testing.T) { + root, state := t.TempDir(), t.TempDir() + c := testCoordinator(t, root, state) + path := filepath.Join(root, "a") + parent, err := c.Acquire(t.Context(), intent(path)) + if err != nil { + t.Fatal(err) + } + defer parent.End(NotExecuted) + ctx := parent.Context(t.Context()) + child, err := c.Acquire(ctx, intent(path)) + if err != nil { + t.Fatal(err) + } + child.End(NotExecuted) + if _, err := c.Acquire(ctx, intent(filepath.Join(root, "b"))); !errors.Is(err, ErrInvalidIntent) { + t.Fatalf("nested scope widened: %v", err) + } + other, cancel := context.WithTimeout(t.Context(), 40*time.Millisecond) + defer cancel() + if _, err := c.Acquire(other, intent(path)); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("child released parent: %v", err) + } +} diff --git a/internal/state/mutation/doc.go b/internal/state/mutation/doc.go new file mode 100644 index 0000000000..894ae6a47b --- /dev/null +++ b/internal/state/mutation/doc.go @@ -0,0 +1,11 @@ +// Package mutation admits complete resource sets for short write transactions. +// It coordinates cooperating processes; it does not grant filesystem access, +// execute tools, or provide a sandbox. Callers authorize the complete effect +// before acquisition, recheck their own preconditions, perform the mutation, +// and end the lease after physical execution settles. +// +// Coordinators that can touch the same files must share a trusted state +// directory. The registry serializes admission, not execution. Live OS locks +// distinguish a queued or active transaction from a dead owner. Compatibility +// claims retain exclusion against older workspacelease writers. +package mutation diff --git a/internal/state/mutation/fairness_test.go b/internal/state/mutation/fairness_test.go new file mode 100644 index 0000000000..105940b80a --- /dev/null +++ b/internal/state/mutation/fairness_test.go @@ -0,0 +1,67 @@ +package mutation + +import ( + "context" + "path/filepath" + "testing" + "time" +) + +func TestOverlappingQueueIsFIFOAndDisjointWorkProceeds(t *testing.T) { + root, state := t.TempDir(), t.TempDir() + coordinator := testCoordinator(t, root, state) + a, b, c := filepath.Join(root, "a"), filepath.Join(root, "b"), filepath.Join(root, "c") + held, err := coordinator.Acquire(t.Context(), intent(a)) + if err != nil { + t.Fatal(err) + } + defer held.End(NotExecuted) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + type result struct { + name string + lease *Lease + err error + } + done := make(chan result, 2) + start := func(name string, paths ...string) { + queued := make(chan struct{}, 1) + waiter, err := New(root, state, func(n Notice) { + if n.State == "waiting" { + queued <- struct{}{} + } + }) + if err != nil { + t.Fatal(err) + } + go func() { lease, err := waiter.Acquire(ctx, intent(paths...)); done <- result{name, lease, err} }() + select { + case <-queued: + case <-ctx.Done(): + t.Fatal("waiter did not enter queue") + } + } + start("first", a, b) + start("second", a) + disjoint, err := coordinator.Acquire(ctx, intent(c)) + if err != nil { + t.Fatalf("disjoint work blocked: %v", err) + } + disjoint.End(NotExecuted) + held.End(NotExecuted) + first := <-done + if first.lease != nil { + defer first.lease.End(NotExecuted) + } + if first.err != nil || first.name != "first" { + t.Fatalf("queue head: %+v", first) + } + first.lease.End(NotExecuted) + second := <-done + if second.lease != nil { + defer second.lease.End(NotExecuted) + } + if second.err != nil || second.name != "second" { + t.Fatalf("queue successor: %+v", second) + } +} diff --git a/internal/state/mutation/notice_test.go b/internal/state/mutation/notice_test.go new file mode 100644 index 0000000000..ffd67f28bb --- /dev/null +++ b/internal/state/mutation/notice_test.go @@ -0,0 +1,117 @@ +package mutation + +import ( + "context" + "errors" + "os" + "path/filepath" + "slices" + "testing" + + "reasonix/internal/state/workspacelease" +) + +func TestLegacyWaitClosesWithoutPrematureAcquisition(t *testing.T) { + root, state := t.TempDir(), t.TempDir() + legacy, err := workspacelease.New(root, state, nil) + if err != nil { + t.Fatal(err) + } + legacy.BeginRun() + defer legacy.EndRun() + path := filepath.Join(root, "a") + if err := legacy.AcquirePaths(t.Context(), []string{path}); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + var states []string + c, err := New(root, state, func(n Notice) { + states = append(states, n.State) + if n.State == "waiting" { + cancel() + } + }) + if err != nil { + t.Fatal(err) + } + if _, err := c.Acquire(ctx, intent(path)); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + if !slices.Equal(states, []string{"waiting", "abandoned"}) { + t.Fatalf("events: %v", states) + } + legacy.EndRun() + lease, err := c.Acquire(t.Context(), intent(path)) + if err != nil { + t.Fatalf("refusal retained a resource: %v", err) + } + lease.End(NotExecuted) +} + +func TestWaitNoticesRetainIdentityAndTerminationReason(t *testing.T) { + for _, finish := range []string{"acquired", "cancelled", "conflict"} { + t.Run(finish, func(t *testing.T) { + root, state := t.TempDir(), t.TempDir() + path := filepath.Join(root, "a") + holder := testCoordinator(t, root, state) + held, err := holder.Acquire(t.Context(), intent(path)) + if err != nil { + t.Fatal(err) + } + defer held.End(NotExecuted) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + var notices []Notice + c, err := New(root, state, func(n Notice) { + if n.State == "released" { + return + } + notices = append(notices, n) + if n.State != "waiting" { + return + } + switch finish { + case "cancelled": + cancel() + case "conflict": + if err := os.WriteFile(path, []byte("changed"), 0600); err != nil { + t.Fatal(err) + } + held.End(Committed) + default: + held.End(NotExecuted) + } + }) + if err != nil { + t.Fatal(err) + } + request := intent(path) + request.Expected = map[string]ContentVersion{path: {Kind: "absent"}} + lease, err := c.Acquire(ctx, request) + if lease != nil { + lease.End(NotExecuted) + } + if (finish == "acquired") != (err == nil) { + t.Fatalf("acquire: %v", err) + } + if len(notices) != 2 { + t.Fatalf("notices: %+v", notices) + } + start, end := notices[0], notices[1] + if start.TransactionID == "" || end.TransactionID != start.TransactionID || end.StartedAt != start.StartedAt || start.State != "waiting" { + t.Fatalf("unpaired wait: %+v", notices) + } + if start.HolderTransactionID != held.ID || start.HolderExecutionID != "execution" || len(start.HolderPaths) != 1 { + t.Fatalf("missing holder: %+v", start) + } + if finish == "acquired" { + if end.State != "acquired" || end.Reason != "" { + t.Fatalf("end: %+v", end) + } + } else if end.State != "abandoned" || end.Reason != finish { + t.Fatalf("end: %+v", end) + } + }) + } +} diff --git a/internal/state/mutation/process_test.go b/internal/state/mutation/process_test.go new file mode 100644 index 0000000000..71844aa00d --- /dev/null +++ b/internal/state/mutation/process_test.go @@ -0,0 +1,77 @@ +package mutation + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +func TestMutationProcessHelper(t *testing.T) { + if os.Getenv("REASONIX_MUTATION_TEST_HELPER") != "1" { + return + } + c, err := New(os.Getenv("REASONIX_MUTATION_TEST_ROOT"), os.Getenv("REASONIX_MUTATION_TEST_STATE"), nil) + if err != nil { + t.Fatal(err) + } + l, err := c.Acquire(t.Context(), intent(os.Getenv("REASONIX_MUTATION_TEST_PATH"))) + if err != nil { + t.Fatal(err) + } + defer l.End(NotExecuted) + fmt.Println("mutation-ready") + _, _ = bufio.NewReader(os.Stdin).ReadString('\n') +} + +func TestCrossProcessClaimAndCrashRecovery(t *testing.T) { + root, state := t.TempDir(), t.TempDir() + path := filepath.Join(root, "a") + cmd := exec.Command(os.Args[0], "-test.run=^TestMutationProcessHelper$") + cmd.Env = append(os.Environ(), "REASONIX_MUTATION_TEST_HELPER=1", "REASONIX_MUTATION_TEST_ROOT="+root, + "REASONIX_MUTATION_TEST_STATE="+state, "REASONIX_MUTATION_TEST_PATH="+path) + in, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + out, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = in.Close(); _ = cmd.Process.Kill(); _ = cmd.Wait() }) + ready := make(chan string, 1) + go func() { line, _ := bufio.NewReader(out).ReadString('\n'); ready <- line }() + select { + case line := <-ready: + if line != "mutation-ready\n" { + t.Fatal(line) + } + case <-time.After(5 * time.Second): + t.Fatal("child did not acquire") + } + c := testCoordinator(t, root, state) + ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + defer cancel() + if _, err := c.Acquire(ctx, intent(path)); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("cross-process overlap: %v", err) + } + if err := cmd.Process.Kill(); err != nil { + t.Fatal(err) + } + _ = cmd.Wait() + ctx2, cancel2 := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel2() + l, err := c.Acquire(ctx2, intent(path)) + if err != nil { + t.Fatalf("crashed holder was not reclaimed: %v", err) + } + l.End(NotExecuted) +} diff --git a/internal/state/mutation/registry.go b/internal/state/mutation/registry.go new file mode 100644 index 0000000000..a140b8a98f --- /dev/null +++ b/internal/state/mutation/registry.go @@ -0,0 +1,202 @@ +package mutation + +import ( + "context" + "encoding/json" + "errors" + "math" + "os" + "path/filepath" + "strings" + "time" + + "reasonix/internal/base/filelock" + "reasonix/internal/base/fileutil" + "reasonix/internal/state/workspacelease" +) + +type record struct { + Version int `json:"version"` + ID string `json:"id"` + SessionID string `json:"sessionId"` + ExecutionID string `json:"executionId"` + CallID string `json:"callId"` + Paths []string `json:"paths"` + Order uint64 `json:"order"` + Active bool `json:"active"` + StartedAt time.Time `json:"startedAt"` + Effect Effect `json:"effect,omitempty"` + Capacities []Capacity `json:"capacities,omitempty"` + Workspace string `json:"workspace,omitempty"` + waitKind Effect +} + +type blocker struct { + record + kind Effect +} + +func (c *Coordinator) records() ([]record, error) { + entries, err := os.ReadDir(c.dir) + if err != nil { + return nil, err + } + var records []record + for _, entry := range entries { + if !strings.HasSuffix(entry.Name(), ".json") { + continue + } + id := strings.TrimSuffix(entry.Name(), ".json") + data, err := os.ReadFile(c.recordPath(id)) + if err != nil { + return nil, err + } + var r record + if err := json.Unmarshal(data, &r); err != nil { + return nil, errors.Join(ErrProtocol, err) + } + if r.ID != id || r.Order == 0 || !validRecordResources(r) { + return nil, ErrProtocol + } + for _, path := range r.Paths { + if !filepath.IsAbs(path) { + return nil, ErrProtocol + } + } + release, err := filelock.TryAcquire(c.livePath(id)) + if err == nil { + removeErr := os.Remove(c.recordPath(id)) + _ = os.Remove(c.preparationPath(id)) + release() + if removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { + return nil, removeErr + } + continue + } + if !errors.Is(err, filelock.ErrHeld) { + return nil, err + } + records = append(records, r) + } + return records, nil +} + +func (c *Coordinator) save(r record) error { + data, err := json.Marshal(r) + if err != nil { + return err + } + return fileutil.AtomicWriteFile(c.recordPath(r.ID), data, 0600) +} + +func (c *Coordinator) enqueue(ctx context.Context, r *record) error { + unlock, err := filelock.Acquire(ctx, c.guardPath()) + if err != nil { + return err + } + defer unlock() + records, err := c.records() + if err != nil { + return err + } + if _, err := capacityLimits(append(records, *r)); err != nil { + return err + } + r.Order = 1 + for _, other := range records { + if other.Order == math.MaxUint64 { + return ErrProtocol + } + r.Order = max(r.Order, other.Order+1) + } + return c.save(*r) +} + +func (c *Coordinator) tryGrant(ctx context.Context, lease *Lease) (*blocker, error) { + r := &lease.record + held, releasePreparation, err := c.grantBlocker(ctx, *r) + if held != nil || err != nil { + return held, err + } + defer releasePreparation() + var releaseCompat func() + defer func() { + if releaseCompat != nil { + releaseCompat() + } + }() + if len(r.Paths) != 0 { + release, conflict, err := c.compatClaim(c.root, c.compatDir, r.SessionID, r.Paths) + if errors.Is(err, workspacelease.ErrClaimBusy) { + held := &record{Paths: r.Paths} + if conflict != nil { + held.SessionID, held.Paths = conflict.SessionID, conflict.Paths + } + return &blocker{record: *held, kind: NamedPaths}, nil + } + if err != nil { + return nil, err + } + releaseCompat = release + if releaseCompat == nil { + return nil, ErrProtocol + } + } + unlock, err := filelock.Acquire(ctx, c.guardPath()) + if err != nil { + return nil, err + } + defer unlock() + records, err := c.records() + if err != nil { + return nil, err + } + if held, err := c.recordsBlocker(*r, records); held != nil || err != nil { + return held, err + } + r.Active = true + if err := c.save(*r); err != nil { + return nil, err + } + lease.releaseCompat, releaseCompat = releaseCompat, nil + return nil, nil +} + +// Queue order reserves eligibility while compatibility discovery runs outside +// the registry lock. The final locked recheck precedes active publication. +func (c *Coordinator) grantBlocker(ctx context.Context, r record) (*blocker, func(), error) { + unlock, err := filelock.Acquire(ctx, c.guardPath()) + if err != nil { + return nil, nil, err + } + defer unlock() + records, err := c.records() + if err != nil { + return nil, nil, err + } + if held, err := c.recordsBlocker(r, records); held != nil || err != nil { + return held, nil, err + } + release, err := filelock.TryAcquire(c.preparationPath(r.ID)) + return nil, release, err +} + +func (c *Coordinator) recordsBlocker(r record, records []record) (*blocker, error) { + if held := pathBlocker(r, records); held != nil { + return &blocker{record: *held, kind: NamedPaths}, nil + } + held, err := c.capacityBlocker(r, records) + if err != nil || held == nil { + return nil, err + } + return &blocker{record: *held, kind: CapacityOnly}, nil +} + +func pathBlocker(r record, records []record) *record { + for _, other := range records { + if other.ID != r.ID && (other.Active || other.Order < r.Order) && overlaps(r.Paths, other.Paths) { + return &other + } + } + return nil +} diff --git a/internal/state/mutation/resources.go b/internal/state/mutation/resources.go new file mode 100644 index 0000000000..817d3b00fb --- /dev/null +++ b/internal/state/mutation/resources.go @@ -0,0 +1,190 @@ +package mutation + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "slices" + "strings" + + "reasonix/internal/base/fileutil" +) + +func (c *Coordinator) resources(i Intent) ([]string, error) { + paths := i.Paths + switch i.Effect { + case CapacityOnly: + if len(paths) != 0 || len(i.Expected) != 0 || len(i.Capacities) == 0 || i.Recovery { + return nil, ErrInvalidIntent + } + return nil, nil + case ReadOnly, External: + if len(paths) != 0 || len(i.Expected) != 0 || len(i.Capacities) != 0 { + return nil, ErrInvalidIntent + } + return nil, nil + case Workspace: + paths = append([]string{c.root}, paths...) + paths = append(paths, c.workspaceResources...) + case NamedPaths, GitMetadata: + if len(paths) == 0 { + return nil, ErrInvalidIntent + } + default: + return nil, ErrInvalidIntent + } + out := make([]string, 0, len(paths)) + for _, p := range paths { + if strings.TrimSpace(p) == "" { + return nil, ErrInvalidIntent + } + if !filepath.IsAbs(p) { + p = filepath.Join(c.root, p) + } + canonical, err := fileutil.CanonicalWritePath(p) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidIntent, err) + } + out = append(out, canonical) + } + slices.Sort(out) + return slices.Compact(out), nil +} + +func within(root, path string) bool { + if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { + root, path = strings.ToLower(root), strings.ToLower(path) + } + return withinExact(root, path) +} + +func withinExact(root, path string) bool { + rel, err := filepath.Rel(root, path) + return err == nil && (rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))) +} + +// heldWithin authorizes path under root. A case-folded spelling counts only +// when the filesystem resolves that prefix to root itself. +func heldWithin(root, path string) bool { + if withinExact(root, path) { + return true + } + if !within(root, path) { + return false + } + depth := len(strings.Split(filepath.Clean(root), string(filepath.Separator))) + parts := strings.Split(filepath.Clean(path), string(filepath.Separator)) + if len(parts) < depth { + return false + } + prefix := strings.Join(parts[:depth], string(filepath.Separator)) + if prefix == "" { + prefix = string(filepath.Separator) + } + held, err := os.Stat(root) + if err != nil { + return false + } + spelled, err := os.Stat(prefix) + return err == nil && os.SameFile(held, spelled) +} + +func overlaps(a, b []string) bool { + for _, x := range a { + for _, y := range b { + if within(x, y) || within(y, x) { + return true + } + } + } + left, right := existing(a), existing(b) + for _, x := range left { + for _, y := range right { + if os.SameFile(x, y) { + return true + } + } + } + return false +} + +func existing(paths []string) []os.FileInfo { + out := make([]os.FileInfo, 0, len(paths)) + for _, path := range paths { + if info, err := os.Stat(path); err == nil { + out = append(out, info) + } + } + return out +} + +func covers(held, requested []string) bool { + return !slices.ContainsFunc(requested, func(path string) bool { + return !slices.ContainsFunc(held, func(root string) bool { return heldWithin(root, path) }) + }) +} + +func (c *Coordinator) validate(ctx context.Context, i Intent, paths []string) error { + current, err := c.resources(i) + if err != nil { + return err + } + if !slices.Equal(current, paths) { + return ErrInvalidIntent + } + if c.admit != nil && i.Effect != CapacityOnly { + i.Paths = current + if err := c.admit(ctx, i); err != nil { + return err + } + } + return validateExpected(ctx, i.Expected, paths) +} + +// versionProbe rehashes a waiting request's inputs only when their metadata +// moves; the check under a granted lease still reads every version in full. +type versionProbe map[string]os.FileInfo + +func (p versionProbe) check(ctx context.Context, versions map[string]ContentVersion, paths []string) error { + for path, expected := range versions { + info, statErr := os.Lstat(path) + if previous, ok := p[path]; ok && statErr == nil && os.SameFile(previous, info) && + previous.Size() == info.Size() && previous.Mode() == info.Mode() && previous.ModTime().Equal(info.ModTime()) { + continue + } + if err := validateExpected(ctx, map[string]ContentVersion{path: expected}, paths); err != nil { + return err + } + if statErr == nil { + p[path] = info + } + } + return nil +} + +// A preflight may reject stale inputs, but only the repeated check under a +// granted lease authorizes execution against the expected versions. +func validateExpected(ctx context.Context, versions map[string]ContentVersion, paths []string) error { + for path, expected := range versions { + if !filepath.IsAbs(path) { + return ErrInvalidIntent + } + canonical, err := fileutil.CanonicalWritePath(path) + if err != nil { + return err + } + if !slices.ContainsFunc(paths, func(root string) bool { return heldWithin(root, canonical) }) { + return ErrInvalidIntent + } + actual, err := ReadVersionContext(ctx, path) + if err != nil { + return err + } + if actual != expected { + return &VersionConflict{Path: path} + } + } + return nil +} diff --git a/internal/state/mutation/stale_wait_test.go b/internal/state/mutation/stale_wait_test.go new file mode 100644 index 0000000000..81fa662458 --- /dev/null +++ b/internal/state/mutation/stale_wait_test.go @@ -0,0 +1,78 @@ +package mutation + +import ( + "context" + "errors" + "os" + "path/filepath" + "slices" + "testing" + "time" + + "reasonix/internal/state/workspacelease" +) + +func TestStaleWaiterLeavesBeforeCurrentWriterReleases(t *testing.T) { + root, state := t.TempDir(), t.TempDir() + path := filepath.Join(root, "shared") + holder := testCoordinator(t, root, state) + held, err := holder.Acquire(t.Context(), intent(path)) + if err != nil { + t.Fatal(err) + } + defer held.End(NotExecuted) + var notices []Notice + waiter, err := New(root, state, func(n Notice) { + notices = append(notices, n) + if n.State == "waiting" { + if err := os.WriteFile(path, []byte("writer postimage"), 0600); err != nil { + t.Fatal(err) + } + } + }) + if err != nil { + t.Fatal(err) + } + request := intent(path) + request.Expected = map[string]ContentVersion{path: {Kind: "absent"}} + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + lease, err := waiter.Acquire(ctx, request) + lease.End(NotExecuted) + if !errors.Is(err, ErrVersionConflict) { + t.Fatalf("stale waiter waited for resource release: %v", err) + } + if len(notices) != 2 || notices[0].State != "waiting" || notices[1].State != "abandoned" || notices[1].Reason != "conflict" { + t.Fatalf("stale wait was not closed as a conflict: %+v", notices) + } + if notices[0].TransactionID != notices[1].TransactionID { + t.Fatal("conflict closed another wait") + } + if err := held.CheckPaths([]string{path}); err != nil { + t.Fatalf("stale waiter disturbed the writer: %v", err) + } + records, err := waiter.records() + if err != nil || slices.ContainsFunc(records, func(r record) bool { return r.ID != held.ID }) { + t.Fatalf("stale queue entry retained: %+v %v", records, err) + } +} + +func TestAlreadyStaleRequestDoesNotEnterCompatibilityPreparation(t *testing.T) { + root, state := t.TempDir(), t.TempDir() + path := filepath.Join(root, "shared") + if err := os.WriteFile(path, []byte("new"), 0600); err != nil { + t.Fatal(err) + } + c := testCoordinator(t, root, state) + c.compatClaim = func(string, string, string, []string) (func(), *workspacelease.ConflictError, error) { + t.Fatal("stale request entered compatibility preparation") + return nil, nil, ErrProtocol + } + request := intent(path) + request.Expected = map[string]ContentVersion{path: {Kind: "absent"}} + lease, err := c.Acquire(t.Context(), request) + lease.End(NotExecuted) + if !errors.Is(err, ErrVersionConflict) { + t.Fatalf("stale request: %v", err) + } +} diff --git a/internal/state/mutation/try_test.go b/internal/state/mutation/try_test.go new file mode 100644 index 0000000000..fedaa01028 --- /dev/null +++ b/internal/state/mutation/try_test.go @@ -0,0 +1,38 @@ +package mutation + +import ( + "errors" + "path/filepath" + "testing" +) + +func TestTryAcquireRefusesConflictsAndLeavesNoNewOccupancy(t *testing.T) { + root, state := t.TempDir(), t.TempDir() + coordinator := testCoordinator(t, root, state) + held, err := coordinator.Acquire(t.Context(), intent(filepath.Join(root, "a"))) + if err != nil { + t.Fatal(err) + } + defer held.End(NotExecuted) + other := testCoordinator(t, root, state) + if got, err := other.TryAcquire(t.Context(), intent(root)); got != nil || !errors.Is(err, ErrBusy) { + t.Fatalf("conflict did not refuse: %v %v", got, err) + } + records, err := other.records() + if err != nil || len(records) != 1 || records[0].ID != held.ID { + t.Fatalf("failed probe retained resources: %+v %v", records, err) + } + disjoint, err := other.TryAcquire(t.Context(), intent(filepath.Join(root, "b"))) + if err != nil { + t.Fatal(err) + } + disjoint.End(NotExecuted) + child, err := coordinator.TryAcquire(held.Context(t.Context()), intent(filepath.Join(root, "a", "child"))) + if err != nil { + t.Fatal(err) + } + child.End(NotExecuted) + if err := held.CheckPaths([]string{filepath.Join(root, "a")}); err != nil { + t.Fatal("probe released its parent's lease") + } +} diff --git a/internal/state/mutation/types.go b/internal/state/mutation/types.go new file mode 100644 index 0000000000..a808aea5b7 --- /dev/null +++ b/internal/state/mutation/types.go @@ -0,0 +1,102 @@ +package mutation + +import ( + "errors" + "time" +) + +type Effect string + +const ( + ReadOnly Effect = "read_only" + NamedPaths Effect = "named_paths" + Workspace Effect = "workspace" + GitMetadata Effect = "git_metadata" + External Effect = "external" + CapacityOnly Effect = "capacity" +) + +var ( + ErrInvalidIntent = errors.New("invalid mutation intent") + ErrVersionConflict = errors.New("mutation input changed") + ErrWaitTimeout = errors.New("mutation wait exhausted") + ErrCapacityWaitTimeout = errors.New("execution capacity wait exhausted") + ErrProtocol = errors.New("unsupported mutation coordination record") + ErrCallback = errors.New("mutation coordination callback failed") + ErrBusy = errors.New("mutation resources are busy") +) + +type Intent struct { + tryOnly bool + Effect Effect + Paths []string + SessionID string + ExecutionID string + CallID string + Expected map[string]ContentVersion + Recovery bool + Capacities []Capacity +} + +type Outcome string + +const ( + NotExecuted Outcome = "not_executed" + Committed Outcome = "committed" + Partial Outcome = "partial" + Unknown Outcome = "unknown" +) + +type Notice struct { + Effect Effect + BlockedOn Effect + Capacities []Capacity + TransactionID string + SessionID string + ExecutionID string + CallID string + Paths []string + HolderSessionID string + HolderExecutionID string + HolderTransactionID string + HolderPaths []string + State string + Reason string + StartedAt time.Time + Elapsed time.Duration + Outcome Outcome +} + +type WaitError struct { + Effect Effect + Capacities []Capacity + TransactionID string + Paths []string + Cause error +} + +func (e *WaitError) Error() string { + if e.Effect == CapacityOnly { + return "execution capacity is busy (transaction " + e.TransactionID + "); reschedule the request: " + e.Cause.Error() + } + return "mutation resources are busy (transaction " + e.TransactionID + "); reschedule the operation: " + e.Cause.Error() +} +func (e *WaitError) Unwrap() error { return e.Cause } +func (e *WaitError) Is(target error) bool { + if e.Effect == CapacityOnly { + return target == ErrCapacityWaitTimeout + } + return target == ErrWaitTimeout +} +func (e *WaitError) RefusalCode() string { + if e.Effect == CapacityOnly { + return "execution.capacity_wait_timeout" + } + return "workspace.mutation_wait_timeout" +} + +type VersionConflict struct{ Path string } + +func (e *VersionConflict) Error() string { return "file changed before the mutation: " + e.Path } +func (*VersionConflict) Is(target error) bool { return target == ErrVersionConflict } +func (*VersionConflict) RefusalCode() string { return "workspace.mutation_version_conflict" } diff --git a/internal/state/mutation/version.go b/internal/state/mutation/version.go new file mode 100644 index 0000000000..31f83a8005 --- /dev/null +++ b/internal/state/mutation/version.go @@ -0,0 +1,21 @@ +package mutation + +import ( + "context" + "errors" + + "reasonix/internal/base/fileutil" +) + +type ContentVersion = fileutil.ContentVersion + +func ReadVersionContext(ctx context.Context, path string) (ContentVersion, error) { + version, err := fileutil.ReadContentVersion(ctx, path) + if errors.Is(err, fileutil.ErrContentChanged) { + return ContentVersion{}, &VersionConflict{Path: path} + } + if errors.Is(err, errors.ErrUnsupported) { + return ContentVersion{}, ErrInvalidIntent + } + return version, err +} diff --git a/internal/state/mutation/workspace_resources_test.go b/internal/state/mutation/workspace_resources_test.go new file mode 100644 index 0000000000..41c5b3069d --- /dev/null +++ b/internal/state/mutation/workspace_resources_test.go @@ -0,0 +1,113 @@ +package mutation + +import ( + "context" + "errors" + "path/filepath" + "sync/atomic" + "testing" +) + +func TestOpaqueWorktreeWriteCoordinatesSharedMetadataOnlyDuringMutation(t *testing.T) { + root, linked, state := t.TempDir(), t.TempDir(), t.TempDir() + common := filepath.Join(root, ".git") + owner := testCoordinator(t, root, state) + held, err := owner.Acquire(t.Context(), Intent{Effect: GitMetadata, Paths: []string{common}}) + if err != nil { + t.Fatal(err) + } + defer held.End(NotExecuted) + notices := make(chan Notice, 8) + c, err := New(linked, state, func(n Notice) { notices <- n }, Options{WorkspaceResources: []string{common}}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan error, 1) + go func() { + lease, err := c.Acquire(ctx, Intent{Effect: Workspace}) + if lease != nil { + lease.End(NotExecuted) + } + done <- err + }() + start := awaitCapacityNotice(t, notices, "waiting") + if start.HolderTransactionID != held.ID { + t.Fatal("opaque write did not wait for shared metadata") + } + other, err := owner.Acquire(t.Context(), Intent{Effect: NamedPaths, Paths: []string{filepath.Join(root, "independent.txt")}}) + if err != nil { + t.Fatal(err) + } + other.End(NotExecuted) + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + end := awaitCapacityNotice(t, notices, "abandoned") + if end.TransactionID != start.TransactionID { + t.Fatal("unclosed opaque metadata wait") + } +} + +func TestContinuationRepeatsOpaqueResourceAdmission(t *testing.T) { + var changed atomic.Bool + c, err := New(t.TempDir(), t.TempDir(), nil, Options{AdmissionCheck: func(_ context.Context, i Intent) error { + if i.Effect == Workspace && changed.Load() { + return ErrInvalidIntent + } + return nil + }}) + if err != nil { + t.Fatal(err) + } + lease, err := c.Acquire(t.Context(), Intent{Effect: Workspace}) + if err != nil { + t.Fatal(err) + } + continuation, err := lease.PrepareContinuation() + if err != nil { + t.Fatal(err) + } + lease.End(NotExecuted) + changed.Store(true) + if next, err := continuation.Acquire(t.Context()); !errors.Is(err, ErrInvalidIntent) { + if next != nil { + next.End(NotExecuted) + } + t.Fatalf("background admission forgot opaque resource identity: %v", err) + } +} + +func TestContextAdmissionCancellationReleasesAllResources(t *testing.T) { + entered := make(chan struct{}) + root, state := t.TempDir(), t.TempDir() + c, err := New(root, state, nil, Options{AdmissionCheck: func(ctx context.Context, _ Intent) error { + close(entered) + <-ctx.Done() + return ctx.Err() + }}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan error, 1) + go func() { + lease, err := c.Acquire(ctx, Intent{Effect: Workspace}) + lease.End(NotExecuted) + done <- err + }() + <-entered + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled admission: %v", err) + } + other := testCoordinator(t, root, state) + lease, err := other.Acquire(t.Context(), Intent{Effect: Workspace}) + if err != nil { + t.Fatal(err) + } + lease.End(NotExecuted) +} diff --git a/internal/state/workspacelease/claim_attempt.go b/internal/state/workspacelease/claim_attempt.go new file mode 100644 index 0000000000..22e87acd53 --- /dev/null +++ b/internal/state/workspacelease/claim_attempt.go @@ -0,0 +1,45 @@ +package workspacelease + +import ( + "errors" + "sync" +) + +var ErrClaimBusy = errors.New("workspace claim cannot be acquired immediately") +var ErrInvalidClaim = errors.New("workspace claim requires explicit resolvable paths") + +// TryClaimPaths makes one non-blocking, independent compatibility claim. +// A successful caller owns the idempotent release, without an Owner run lifetime. +func TryClaimPaths(root, lockDir, sessionID string, paths []string) (func(), *ConflictError, error) { + return claimAttempt(root, lockDir, sessionID, paths, true) +} + +// ProbeClaimPaths checks current compatibility contention without retaining claims. +func ProbeClaimPaths(root, lockDir string, paths []string) (*ConflictError, error) { + _, conflict, err := claimAttempt(root, lockDir, "", paths, false) + return conflict, err +} + +func claimAttempt(root, lockDir, sessionID string, paths []string, publish bool) (func(), *ConflictError, error) { + if len(paths) == 0 { + return nil, nil, ErrInvalidClaim + } + o, err := New(root, lockDir, nil) + if err != nil { + return nil, nil, err + } + o.scope.sessionID = func() string { return sessionID } + normalized := o.normalizePaths(paths) + if len(normalized) != len(paths) { + return nil, nil, ErrInvalidClaim + } + release, conflict, err := o.tryPathSet(normalized, false, publish) + if errors.Is(err, errHeld) { + return nil, conflict, ErrClaimBusy + } + if err != nil || release == nil { + return nil, conflict, err + } + var once sync.Once + return func() { once.Do(release) }, nil, nil +} diff --git a/internal/state/workspacelease/claim_attempt_test.go b/internal/state/workspacelease/claim_attempt_test.go new file mode 100644 index 0000000000..b54f4282bf --- /dev/null +++ b/internal/state/workspacelease/claim_attempt_test.go @@ -0,0 +1,60 @@ +package workspacelease + +import ( + "errors" + "path/filepath" + "testing" +) + +func TestClaimAttemptAndProbeDoNotRetainPartialClaims(t *testing.T) { + root, locks := t.TempDir(), t.TempDir() + paths := []string{filepath.Join(root, "a")} + if _, err := ProbeClaimPaths(root, locks, paths); err != nil { + t.Fatal(err) + } + first, _, err := TryClaimPaths(root, locks, "first", paths) + if err != nil { + t.Fatal(err) + } + defer first() + for _, probe := range []bool{false, true} { + var conflict *ConflictError + if probe { + conflict, err = ProbeClaimPaths(root, locks, paths) + } else { + _, conflict, err = TryClaimPaths(root, locks, "second", paths) + } + if !errors.Is(err, ErrClaimBusy) || conflict == nil || conflict.SessionID != "first" { + t.Fatalf("missing immediate conflict: %+v %v", conflict, err) + } + } + first() + second, _, err := TryClaimPaths(root, locks, "second", paths) + if err != nil { + t.Fatal(err) + } + defer second() + first() + if conflict, err := ProbeClaimPaths(root, locks, paths); !errors.Is(err, ErrClaimBusy) || conflict.SessionID != "second" { + t.Fatalf("old release cleared a new claim: %+v %v", conflict, err) + } + if _, _, err := TryClaimPaths(root, locks, "invalid", nil); !errors.Is(err, ErrInvalidClaim) { + t.Fatal(err) + } +} + +func TestClaimAttemptHonorsLegacyWholeWorkspaceLock(t *testing.T) { + root, locks := t.TempDir(), t.TempDir() + o, err := New(root, locks, nil) + if err != nil { + t.Fatal(err) + } + release, err := tryLockFile(o.lockPath) + if err != nil { + t.Fatal(err) + } + defer release() + if _, _, err := TryClaimPaths(root, locks, "new", []string{filepath.Join(root, "file")}); !errors.Is(err, ErrClaimBusy) { + t.Fatalf("bypassed old exclusive lock: %v", err) + } +} diff --git a/internal/state/workspacelease/claim_panic_test.go b/internal/state/workspacelease/claim_panic_test.go new file mode 100644 index 0000000000..7bac287f99 --- /dev/null +++ b/internal/state/workspacelease/claim_panic_test.go @@ -0,0 +1,46 @@ +package workspacelease + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "reasonix/internal/base/fileutil" +) + +func TestCompatibilityClaimPanicBeforeHandoffReleasesItsNewLocks(t *testing.T) { + root, state := t.TempDir(), t.TempDir() + path := filepath.Join(root, "a") + before := fileutil.CrashPoint + fileutil.CrashPoint = func(op, target string) { + if op == "atomic-write" && strings.Contains(target, ".claims") { + panic("claim write failed before ownership handoff") + } + } + func() { + defer func() { + if recover() == nil { + t.Error("fixture did not fail at claim publication") + } + }() + _, _, _ = TryClaimPaths(root, state, "first", []string{path}) + }() + fileutil.CrashPoint = before + t.Cleanup(func() { fileutil.CrashPoint = before }) + claimDirs, err := filepath.Glob(filepath.Join(state, "*.claims")) + if err != nil || len(claimDirs) == 0 { + t.Fatalf("fixture did not reach claim publication: %v", err) + } + for _, dir := range claimDirs { + entries, err := os.ReadDir(dir) + if err != nil || len(entries) != 0 { + t.Fatalf("failed handoff retained unpublished claim resources: %v %v", entries, err) + } + } + release, _, err := TryClaimPaths(root, state, "second", []string{path}) + if err != nil { + t.Fatalf("failed handoff retained physical compatibility ownership: %v", err) + } + release() +} diff --git a/internal/state/workspacelease/paths.go b/internal/state/workspacelease/paths.go index 6ab2885869..b5f72c7a02 100644 --- a/internal/state/workspacelease/paths.go +++ b/internal/state/workspacelease/paths.go @@ -151,6 +151,10 @@ func (o *Owner) acquirePaths(ctx context.Context, paths []string, held bool) (fu } func (o *Owner) tryPaths(paths []string, held bool) (func(), *ConflictError, error) { + return o.tryPathSet(paths, held, true) +} + +func (o *Owner) tryPathSet(paths []string, held, publish bool) (func(), *ConflictError, error) { select { case <-o.local.token: default: @@ -224,6 +228,9 @@ func (o *Owner) tryPaths(paths []string, held bool) (func(), *ConflictError, err return nil, o.conflict(record.Holder, record.SessionID, record.Paths, paths), errHeld } } + if !publish { + return nil, nil, nil + } if !held { var id [16]byte if _, err := rand.Read(id[:]); err != nil { @@ -239,18 +246,24 @@ func (o *Owner) tryPaths(paths []string, held bool) (func(), *ConflictError, err return nil, nil, err } } - if err := o.writeClaim(path, paths); err != nil { + defer func() { if release != nil { + _ = os.Remove(path) release() + _ = os.Remove(path + ".lock") } + }() + if err := o.writeClaim(path, paths); err != nil { return nil, nil, err } if held { return nil, nil, nil } sharedRelease := shared + claimRelease := release + release = nil shared = nil - return func() { o.releaseClaim(path, release); sharedRelease() }, nil, nil + return func() { o.releaseClaim(path, claimRelease); sharedRelease() }, nil, nil } func (o *Owner) releaseClaim(path string, release func()) { From 04bdd5702b0cb9a3255f52e02aec29f76d7e9fde Mon Sep 17 00:00:00 2001 From: Lancy Date: Fri, 23 Oct 2026 13:25:24 +0800 Subject: [PATCH 2/3] fix(studio): execution typography follows body preferences --- .../perf/activity-typography.mjs | 127 ++++++++++++++++++ desktop/frontend-next/perf/image-preview.mjs | 2 +- desktop/frontend-next/src/styles/TOKENS.md | 12 ++ desktop/frontend-next/src/styles/studio.css | 26 ++-- desktop/frontend-next/src/styles/tokens.css | 1 + desktop/frontend-next/src/ui/look.ts | 6 +- 6 files changed, 157 insertions(+), 17 deletions(-) create mode 100644 desktop/frontend-next/perf/activity-typography.mjs diff --git a/desktop/frontend-next/perf/activity-typography.mjs b/desktop/frontend-next/perf/activity-typography.mjs new file mode 100644 index 0000000000..46e731399e --- /dev/null +++ b/desktop/frontend-next/perf/activity-typography.mjs @@ -0,0 +1,127 @@ +import { chromium } from "playwright"; + +const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html"; +const fails = []; +const check = (name, ok, detail) => { + console.log(`${ok ? " ok" : "FAIL"} ${name}: ${JSON.stringify(detail)}`); + if (!ok) fails.push(name); +}; +const textKeys = ["title", "count", "name", "arg", "normalization", "originalInput"]; +const weightKeys = textKeys.filter(key => key !== "title" && key !== "count"); +const browser = await chromium.launch(); +for (const lang of ["zh", "en"]) { + for (const fold of [false, true]) { + const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, reducedMotion: "reduce" }); + page.on("pageerror", (e) => fails.push(e.message)); + await page.goto(`${BASE}?pref=${lang}${fold ? "&fold" : ""}`, { waitUntil: "networkidle" }); + await page.waitForSelector(".app"); + await page.evaluate(async () => { + const frame = () => new Promise((r) => requestAnimationFrame(r)); + window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "Inspect the file" }); + window.__feed({ kind: "text", text: "I will inspect it first." }); + window.__feed({ kind: "message", text: "I will inspect it first." }); + const tool = { + id: "typography-read", name: "read_file", args: '{"path":"src/main.go"}', + argumentNormalization: { reason: "json_string_unwrapped", original: '"{\\"path\\":\\"src/main.go\\"}"', effective: '{"path":"src/main.go"}' }, + }; + window.__feed({ kind: "tool_dispatch", tool }); + window.__feed({ kind: "tool_result", tool: { ...tool, output: "package main", durationMs: 120 } }); + await frame(); + window.__feed({ kind: "text", text: "The file declares package main." }); + window.__feed({ kind: "message", text: "The file declares package main." }); + window.__feed({ kind: "turn_done" }); + }); + await page.waitForSelector(".activity-title"); + while (await page.locator(".activity-group:not([open]) > summary").count()) { + await page.locator(".activity-group:not([open]) > summary").first().click(); + } + await page.waitForSelector(".activity-body .hl .nm", { state: "visible" }); + await page.locator(".activity-body .tool-disclosure > summary .nm").first().click(); + await page.waitForSelector(".activity-call-input > details > summary", { state: "visible" }); + check(`${lang}/${fold}: original input starts folded`, await page.locator(".activity-call-input > details").evaluate((el) => !el.open)); + await page.locator(".activity-call-input > details > summary").click(); + check(`${lang}/${fold}: original input opens with its source`, await page.locator(".activity-call-input > details pre").textContent() === '"{\\"path\\":\\"src/main.go\\"}"'); + const measure = async () => { + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + return page.evaluate(() => { + const style = (selector) => { + const s = getComputedStyle(document.querySelector(selector)); + return { size: parseFloat(s.fontSize), weight: Number(s.fontWeight), family: s.fontFamily }; + }; + return { + title: style(".activity-title"), count: style(".activity-count"), + name: style(".activity-body .hl .nm"), arg: style(".activity-body .hl .arg"), + normalization: style(".activity-call-input > p"), originalInput: style(".activity-call-input > details > summary"), + sidebar: style(".sesstitle"), answer: style('.call[data-k="say"] .out .txt'), + }; + }); + }; + const base = await measure(); + check(`${lang}/${fold}: activity labels preserve the 11px/500 default`, base.title.size === 11 && base.count.size === 11 && base.title.weight === 500 && base.count.weight === 500, { title: base.title, count: base.count }); + const pick = async (action, index) => { + await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]').click()); + await page.waitForSelector(".prefs-nav button"); + await page.evaluate(() => document.getElementById("prefs-appearance").click()); + const variable = action === "appearance.reading-size" ? "--read" : "--fs-scale"; + const expected = action === "appearance.reading-size" + ? `${(lang === "zh" ? [12, 13, 15, 17] : [11.5, 12.5, 14.5, 16.5])[index]}px` : "1.3"; + await page.locator(`[data-action="${action}"]`).nth(index).click(); + await page.waitForFunction(([name, value]) => document.documentElement.style.getPropertyValue(name) === value, [variable, expected]); + await page.keyboard.press("Escape"); + }; + await pick("appearance.reading-size", 3); + const large = await measure(); + const ratio = large.answer.size / base.answer.size; + const tag = `${lang}/${fold ? "folded turn" : "activity"}`; + for (const key of textKeys) { + check(`${tag} ${key} follows body size`, Math.abs(large[key].size / base[key].size - ratio) < .02, { before: base[key].size, after: large[key].size, ratio }); + } + check(`${tag} body size leaves sidebar alone`, large.sidebar.size === base.sidebar.size, large.sidebar); + await pick("appearance.text-size", 3); + const uiLarge = await measure(); + for (const key of textKeys) { + const expectedRatio = key === "title" || key === "count" ? 1.3 : 1; + check(`${tag} ${key} ${expectedRatio === 1 ? "independent of" : "scales with"} interface size`, + Math.abs(uiLarge[key].size / large[key].size - expectedRatio) < .02, uiLarge[key]); + } + check(`${tag} interface size still enlarges sidebar`, uiLarge.sidebar.size > large.sidebar.size, uiLarge.sidebar); + await page.evaluate(() => { document.documentElement.dataset.weight = "light"; }); + const light = await measure(); + await page.evaluate(() => { document.documentElement.dataset.weight = "heavy"; }); + const heavy = await measure(); + for (const key of weightKeys) { + check(`${tag} ${key} follows weight preference`, heavy[key].weight > light[key].weight, { light: light[key].weight, heavy: heavy[key].weight }); + } + check(`${tag} activity labels retain one 500 weight`, heavy.title.weight === 500 && heavy.count.weight === 500, { title: heavy.title, count: heavy.count }); + await page.evaluate(() => { + document.documentElement.style.setProperty("--ui", "Georgia, serif"); + document.documentElement.style.setProperty("--mono", "Menlo, monospace"); + }); + const fonts = await measure(); + check(`${tag} labels use prose family; commands use mono`, fonts.title.family.startsWith("Georgia") && fonts.name.family.startsWith("Menlo") && fonts.arg.family.startsWith("Menlo"), fonts); + await page.evaluate(() => { + window.__feed({ kind: "turn_started", authoredTurn: 2, msgIndex: 1, text: "Inspect failure and running labels" }); + const failed = { id: "scale-failure", name: "bash", args: '{"command":"false"}' }; + window.__feed({ kind: "tool_dispatch", tool: failed }); + window.__feed({ kind: "tool_result", tool: { ...failed, err: "exit status 1", output: "failed", durationMs: 120 } }); + window.__feed({ kind: "tool_dispatch", tool: { id: "scale-running", name: "bash", args: '{"command":"sleep 10"}' } }); + }); + await page.waitForSelector(".activity-running"); + for (const step of [0, 1, 2, 3]) { + await pick("appearance.reading-size", step); + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + const labels = await page.locator(".activity-group[data-running] > summary").evaluate(el => + [...el.querySelectorAll(".activity-title, .activity-running, .activity-errors, .activity-count")].map(span => { + const css = getComputedStyle(span); + return { label: span.className, size: parseFloat(css.fontSize), weight: Number(css.fontWeight), lineHeight: css.lineHeight }; + })); + const read = (lang === "zh" ? [12, 13, 15, 17] : [11.5, 12.5, 14.5, 16.5])[step]; + const expected = 11 * 1.3 * read / (lang === "zh" ? 13 : 12.5); + check(`${tag} body step ${step}: title/running/failed/count compose text and reading scales at weight 500`, + labels.length === 4 && labels.every(label => Math.abs(label.size - expected) < .01 && label.weight === 500 && label.lineHeight === labels[0].lineHeight), labels); + } + await page.close(); + } +} +await browser.close(); +if (fails.length) throw new Error(`${fails.length} checks failed: ${fails.join(", ")}`); diff --git a/desktop/frontend-next/perf/image-preview.mjs b/desktop/frontend-next/perf/image-preview.mjs index af58648509..471929f6d5 100644 --- a/desktop/frontend-next/perf/image-preview.mjs +++ b/desktop/frontend-next/perf/image-preview.mjs @@ -61,7 +61,7 @@ try { assert.equal(await page.locator(".user-image button").evaluate(el => el === document.activeElement), true); await page.locator(".activity-group > summary").click(); const tool = page.locator('[data-call="image-shot"] .tool-disclosure'); - await tool.locator("summary").click(); + await tool.locator("summary .nm").click(); await page.getByRole("button", { name: "查看图片 工具截图 1", exact: true }).click(); assert.equal(await page.getByRole("dialog").locator("img").getAttribute("src"), data); await page.getByRole("button", { name: "关闭图片预览" }).click(); diff --git a/desktop/frontend-next/src/styles/TOKENS.md b/desktop/frontend-next/src/styles/TOKENS.md index 7f6888a762..0f6c38fc7f 100644 --- a/desktop/frontend-next/src/styles/TOKENS.md +++ b/desktop/frontend-next/src/styles/TOKENS.md @@ -276,6 +276,18 @@ pack states a shape, never a number the layout has to agree with. - `--read` is the conversation body's own size, separate from interface zoom, so "I cannot read this" adjusts one thing. The value here is the first-frame fallback; the real default is `look.ts`'s `readDefault()`. +- Execution content derives size from `--read`. Appearance also projects + `--read-scale` as the selected body size divided by the language's default. +- The root's `--activity-scale` multiplies that ratio by the interface text scale. + Activity groups inherit the product as their local `--fs-scale`, including + work folded at turn completion. +- Composing at the root avoids self-reference and prevents nested activity + groups from multiplying the scale again. +- Activity title, running, failed and action counts retain one 11px/500 scale, + 1.5 line-height and the running chip's 2px vertical padding. Interface and + body-size changes scale all four equally; the common 500 weight stays fixed. + Tool rows and input details retain reading/weight preferences. +- Labels use `--ui`; tool names, paths and input use `--mono`. - `--column` is the reading column's width. The composer is laid at it in every measure; the transcript takes it through `--doc`. - `--doc` is the column the transcript is laid in: `--column`, or the whole diff --git a/desktop/frontend-next/src/styles/studio.css b/desktop/frontend-next/src/styles/studio.css index a3069a5650..d02fd6beaf 100644 --- a/desktop/frontend-next/src/styles/studio.css +++ b/desktop/frontend-next/src/styles/studio.css @@ -868,7 +868,7 @@ body { background: var(--page) !important; } color: var(--text); font-family: var(--ui); font-size: var(--read); font-weight: var(--w-reg); line-height: 1.72; letter-spacing: 0; -webkit-font-smoothing: antialiased; } -.activity-group { margin: var(--sp-4) 0 0; overflow: clip; } +.activity-group { --fs-scale: var(--activity-scale, 1); margin: var(--sp-4) 0 0; overflow: clip; } .activity-group > summary { min-height: 28px; padding: 0; display: flex; align-items: center; gap: var(--sp-3); color: var(--faint); font-size: calc(11px * var(--fs-scale, 1)); font-weight: 500; line-height: 1.5; @@ -920,17 +920,17 @@ body { background: var(--page) !important; } } .activity-body .hl .nm { order: -9; flex: 0 1 auto; min-width: 3em; max-width: 96px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - color: #d1d3cd; font: 520 calc(10.5px * var(--fs-scale, 1))/1.3 var(--mono) !important; + color: #d1d3cd; font: var(--w-med) calc(var(--read) * .85)/1.3 var(--mono) !important; } .activity-body .hl .arg { - order: -8; flex: 1 8 auto; color: var(--faint); font: 450 calc(10.5px * var(--fs-scale, 1))/1.3 var(--mono) !important; + order: -8; flex: 1 8 auto; color: var(--faint); font: var(--w-read, var(--w-reg)) calc(var(--read) * .85)/1.3 var(--mono) !important; } -.activity-body .hl :is(.src, .who, .tag) { order: -7; flex: 0 3 auto; min-width: 0; max-width: 12em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: calc(8.5px * var(--fs-scale, 1)); } -.activity-body .cost { order: -6; margin-left: auto; gap: 5px; color: var(--ghost); font-size: calc(9px * var(--fs-scale, 1)); } +.activity-body .hl :is(.src, .who, .tag) { order: -7; flex: 0 3 auto; min-width: 0; max-width: 12em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: calc(var(--read) * .7); } +.activity-body .cost { order: -6; margin-left: auto; gap: 5px; color: var(--ghost); font-size: calc(var(--read) * .75); } .activity-body .cost .took { display: none; } .activity-note { margin: 0; padding: 3px 12px 7px; border-top: 0; - color: var(--faint); font: 450 calc(9px * var(--fs-scale, 1))/1.5 var(--ui); + color: var(--faint); font: var(--w-read, var(--w-reg)) calc(var(--read) * .75)/1.5 var(--ui); } .flow { --out-peek: 4rem; } .flow .call:not([data-k="me"]):not([data-k="say"]) { margin-block: 0 2px; } @@ -984,16 +984,16 @@ body { background: var(--page) !important; } .activity-call-result { display: flex; align-items: center; gap: 8px; } .activity-call-meta { min-height: 25px; justify-content: space-between; padding-right: 5px; - color: var(--ghost); font: 450 calc(9px * var(--fs-scale, 1))/1.4 var(--ui); + color: var(--ghost); font: var(--w-read, var(--w-reg)) calc(var(--read) * .75)/1.4 var(--ui); } .activity-call-input { padding: 5px 5px 8px 0; border-top: 1px solid var(--hair); } .activity-call-input > header, -.activity-call-result { min-height: 23px; color: var(--ghost); font: 500 calc(9px * var(--fs-scale, 1))/1.3 var(--ui); } +.activity-call-result { min-height: 23px; color: var(--ghost); font: var(--w-read, var(--w-reg)) calc(var(--read) * .75)/1.3 var(--ui); } .activity-call-input > header b, -.activity-call-result b { color: var(--muted); font-weight: 560; } -.activity-call-input > header span { margin-left: auto; color: var(--ghost); font: 500 calc(8px * var(--fs-scale, 1))/1 var(--mono); } +.activity-call-result b { color: var(--muted); font-weight: var(--w-med); } +.activity-call-input > header span { margin-left: auto; color: var(--ghost); font: var(--w-med) calc(var(--read) * .65)/1 var(--mono); } .activity-call-input > p, -.activity-call-input > details > summary { display: flex; align-items: center; min-height: 23px; margin: 0; color: var(--ghost); font: 500 calc(9px * var(--fs-scale, 1))/1.3 var(--ui); } +.activity-call-input > details > summary { display: flex; align-items: center; min-height: 23px; margin: 0; color: var(--ghost); font: var(--w-read, var(--w-reg)) calc(var(--read) * .75)/1.3 var(--ui); } .activity-call-input > details > summary { gap: 7px; list-style: none; cursor: pointer; user-select: none; } .activity-call-input > details > summary::-webkit-details-marker { display: none; } .activity-call-input > details > summary::before { content: ""; width: 5px; height: 5px; flex: none; @@ -1003,12 +1003,12 @@ body { background: var(--page) !important; } .activity-call-input pre { max-height: 170px; margin: 2px 0 0; padding: 8px 10px; overflow: auto; border: 1px solid var(--hair); border-radius: var(--r-md); background: var(--face-code); - color: var(--muted); font: var(--w-reg) calc(11.5px * var(--fs-scale, 1))/1.6 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; + color: var(--muted); font: var(--w-reg) calc(var(--read) * .9)/1.6 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; } .activity-call-result { padding: 6px 5px 3px 0; border-top: 1px solid var(--hair); } .activity-call-result button { margin-left: auto; padding: 3px 5px; border: 0; border-radius: 4px; - background: transparent; color: var(--ghost); cursor: pointer; font: 500 calc(8.5px * var(--fs-scale, 1))/1 var(--ui); + background: transparent; color: var(--ghost); cursor: pointer; font: var(--w-med) calc(var(--read) * .7)/1 var(--ui); } .activity-call-result button:hover { color: #cfd1ca; background: var(--float); } .tool-disclosure[open] > summary .tool-fold { transform: rotate(225deg) translate(-1px, 1px); } diff --git a/desktop/frontend-next/src/styles/tokens.css b/desktop/frontend-next/src/styles/tokens.css index a0479feaf4..7180875051 100644 --- a/desktop/frontend-next/src/styles/tokens.css +++ b/desktop/frontend-next/src/styles/tokens.css @@ -129,6 +129,7 @@ --fs-lead: calc(15px * var(--fs-scale, 1)); --fs-title: calc(17px * var(--fs-scale, 1)); --fs-display: calc(22px * var(--fs-scale, 1)); --read: 16px; + --activity-scale: calc(var(--fs-scale, 1) * var(--read-scale, 1)); --column: 760px; --doc: min(100%, var(--column)); --wide: min(100%, 1120px); diff --git a/desktop/frontend-next/src/ui/look.ts b/desktop/frontend-next/src/ui/look.ts index eca20e3a87..6ec29afc09 100644 --- a/desktop/frontend-next/src/ui/look.ts +++ b/desktop/frontend-next/src/ui/look.ts @@ -79,9 +79,9 @@ export function apply(look: Appearance | null, busy = false) { if (look?.textSize && look.textSize !== 1) style.setProperty("--fs-scale", String(look.textSize)); else style.removeProperty("--fs-scale"); - // Reading size moves the transcript's prose alone, so the frame around it - // stays where the layout put it. - style.setProperty("--read", `${readSizeOf(look?.readSize)}px`); + const readSize = readSizeOf(look?.readSize); + style.setProperty("--read", `${readSize}px`); + style.setProperty("--read-scale", String(readSize / readDefault())); // How far that prose runs is the other half of the same question, and the // only one with two honest answers: a line short enough to read without From d28a6089fcf06a2f2c8a95031f4133cb052487d3 Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Sat, 24 Oct 2026 22:54:22 -0700 Subject: [PATCH 3/3] fix(studio): a line-width preset wins over a width the column was dragged to The width a hand set is kept in --flow-w and outranks the stylesheet, so once the reading column had been dragged, choosing the full-width preset changed --doc but not the column and did nothing. Choosing a preset now drops the dragged width. --- .github/workflows/studio.yml | 1 + desktop/frontend-next/perf/flow-preset.mjs | 42 +++++++++++++++++++ desktop/frontend-next/src/ui/Appearance.tsx | 3 +- .../frontend-next/src/ui/FlowResizer.test.tsx | 15 ++++++- desktop/frontend-next/src/ui/FlowResizer.tsx | 15 +++++++ .../measure-preset-drag.interaction.test.tsx | 34 +++++++++++++++ .../14411-measure-preset-after-drag.md | 2 + 7 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 desktop/frontend-next/perf/flow-preset.mjs create mode 100644 desktop/frontend-next/src/ui/measure-preset-drag.interaction.test.tsx create mode 100644 release-notes/studio/unreleased/14411-measure-preset-after-drag.md diff --git a/.github/workflows/studio.yml b/.github/workflows/studio.yml index 5b7e021d9d..8d1768c9d5 100644 --- a/.github/workflows/studio.yml +++ b/.github/workflows/studio.yml @@ -161,6 +161,7 @@ jobs: node perf/rowhit.mjs node perf/flowgrip-quiet.mjs node perf/flow-narrow.mjs + node perf/flow-preset.mjs node perf/tab-pip.mjs node perf/context-card-hover.mjs node perf/sessquick.mjs diff --git a/desktop/frontend-next/perf/flow-preset.mjs b/desktop/frontend-next/perf/flow-preset.mjs new file mode 100644 index 0000000000..8a970c6eeb --- /dev/null +++ b/desktop/frontend-next/perf/flow-preset.mjs @@ -0,0 +1,42 @@ +// A line-width preset still works on a column a hand has already dragged: the +// dragged width outranks the stylesheet, so choosing a preset has to drop it. +import assert from "node:assert/strict"; +import { chromium } from "playwright"; + +const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&turns=3"; +const browser = await chromium.launch(); +try { + const page = await browser.newPage({ viewport: { width: 1900, height: 900 } }); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(PAGE, { waitUntil: "networkidle" }); + await page.waitForSelector(".flowgrip-l"); + const flow = () => page.locator(".flow").first().evaluate((el) => Math.round(el.getBoundingClientRect().width)); + const readable = await flow(); + const box = await page.locator(".flowgrip-l").boundingBox(); + await page.mouse.move(box.x + 3, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + 3 - 60, box.y + box.height / 2, { steps: 6 }); + await page.mouse.up(); + await page.waitForTimeout(200); + const dragged = await flow(); + assert.ok(dragged > readable, `the hand widened the column (${readable} -> ${dragged})`); + + const choose = async (name) => { + await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click()); + await page.waitForSelector(".prefs-nav button"); + await page.locator(".prefs-nav button", { hasText: "外观" }).first().click(); + await page.locator('[data-action="appearance.measure"]', { hasText: new RegExp(`^${name}$`) }).click(); + await page.keyboard.press("Escape"); + await page.waitForTimeout(400); + return flow(); + }; + const full = await choose("铺满窗口"); + assert.ok(full > dragged, `the full-width preset widens a dragged column (${dragged} -> ${full})`); + const standard = await choose("适宜阅读"); + assert.equal(standard, readable, `the reading preset returns to the readable measure (${full} -> ${standard})`); + assert.deepEqual(errors, []); + console.log(`PASS a preset overrides a dragged width: ${readable} -> ${dragged} (dragged) -> ${full} (full) -> ${standard} (reading)`); +} finally { + await browser.close(); +} diff --git a/desktop/frontend-next/src/ui/Appearance.tsx b/desktop/frontend-next/src/ui/Appearance.tsx index 32177ef755..ed7d0d0312 100644 --- a/desktop/frontend-next/src/ui/Appearance.tsx +++ b/desktop/frontend-next/src/ui/Appearance.tsx @@ -26,6 +26,7 @@ import { SendShortcut } from "./SendShortcut"; import { KeysSheet } from "./KeysSheet"; import { SessionTabsSetting } from "./SessionTabsSetting"; import { EscStopSetting } from "./EscStopSetting"; +import { forgetDraggedWidth } from "./FlowResizer"; import { PageSleepSetting } from "./PageSleepSetting"; import { ThemeImport } from "./ThemeImport"; import { ThemeRemove } from "./ThemeRemove"; @@ -365,7 +366,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
{MEASURES.map(([id, name, why]) => ( ))} diff --git a/desktop/frontend-next/src/ui/FlowResizer.test.tsx b/desktop/frontend-next/src/ui/FlowResizer.test.tsx index a29e9ac7a2..5cab224704 100644 --- a/desktop/frontend-next/src/ui/FlowResizer.test.tsx +++ b/desktop/frontend-next/src/ui/FlowResizer.test.tsx @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { act, cleanup, fireEvent, render, renderHook } from "@testing-library/react"; import "./testkit"; -import { barsFit, flowCeiling, flowFloor, flowMin, FlowGrips, useColumn, type Column } from "./FlowResizer"; +import { barsFit, flowCeiling, flowFloor, flowMin, forgetDraggedWidth, FlowGrips, useColumn, type Column } from "./FlowResizer"; const RAIL_MIN = 232; const DOCK_MIN = 320; @@ -219,3 +219,16 @@ describe("the keys walk the same path the hand does", () => { expect(press("l", "ArrowRight", false, 760)).toEqual([748]); }); }); + +describe("a line-width preset answers for the dragged width", () => { + it("forgets what a hand set, in storage and in every mounted column", () => { + localStorage.clear(); + localStorage.setItem("rx-flow-w", "900"); + const { result } = renderHook(() => useColumn(316, 316, RAIL_MIN, DOCK_MIN)); + expect(result.current.dragged).toBe(900); + act(() => forgetDraggedWidth()); + expect(result.current.dragged).toBe(0); + expect(localStorage.getItem("rx-flow-w")).toBeNull(); + expect(result.current.width).toBe(result.current.floor); + }); +}); diff --git a/desktop/frontend-next/src/ui/FlowResizer.tsx b/desktop/frontend-next/src/ui/FlowResizer.tsx index 8cb0ca0ea2..fdf9face0f 100644 --- a/desktop/frontend-next/src/ui/FlowResizer.tsx +++ b/desktop/frontend-next/src/ui/FlowResizer.tsx @@ -21,6 +21,16 @@ const NARROWEST = 640; * the kernel's config. */ export const FLOW_KEY = "rx-flow-w"; +const forgetters = new Set<() => void>(); + +/** A line-width preset and a hand on the grips answer the same question, and + * the width a hand set outranks the stylesheet, so choosing a preset has to + * drop it or the choice changes nothing on a column that was ever dragged. */ +export function forgetDraggedWidth() { + try { localStorage.removeItem(FLOW_KEY); } catch { /* storage is a convenience */ } + forgetters.forEach((forget) => forget()); +} + const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); /** What the column may grow to: the window, less the room the two sidebars need @@ -97,6 +107,11 @@ export function useColumn(railW: number, dockW: number, railMin: number, dockMin }); const [room, setRoom] = useState(() => document.body.clientWidth); useEffect(() => onRoomWidth(setRoom), []); + useEffect(() => { + const forget = () => setDragged(0); + forgetters.add(forget); + return () => { forgetters.delete(forget); }; + }, []); const floor = flowFloor(room, railMin, dockMin); const min = flowMin(room, railMin, dockMin); diff --git a/desktop/frontend-next/src/ui/measure-preset-drag.interaction.test.tsx b/desktop/frontend-next/src/ui/measure-preset-drag.interaction.test.tsx new file mode 100644 index 0000000000..8c73e2ea4f --- /dev/null +++ b/desktop/frontend-next/src/ui/measure-preset-drag.interaction.test.tsx @@ -0,0 +1,34 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "./testkit"; +import { Appearance } from "./Appearance"; +import { FLOW_KEY } from "./FlowResizer"; +import { MockPort } from "../port/mock"; +import { boot, STORAGE } from "../i18n"; +import type { AgentPort } from "../port/port"; + +beforeEach(() => { + localStorage.setItem(STORAGE, "zh"); + boot(); + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null); +}); + +afterEach(() => { + cleanup(); + localStorage.clear(); + vi.restoreAllMocks(); +}); + +function panel(width: string) { + return {}} contrast="" onContrast={() => {}} + weight="" onWeight={() => {}} look={{ width } as never} onLook={() => {}} reloadThemes={() => {}} />; +} + +it.each(["铺满窗口", "适宜阅读"])("choosing %s drops the width a hand dragged the reading column to", async (name) => { + localStorage.setItem(FLOW_KEY, "900"); + render(panel(name === "铺满窗口" ? "standard" : "full")); + await userEvent.click(await screen.findByRole("button", { name })); + expect(localStorage.getItem(FLOW_KEY)).toBeNull(); +}); diff --git a/release-notes/studio/unreleased/14411-measure-preset-after-drag.md b/release-notes/studio/unreleased/14411-measure-preset-after-drag.md new file mode 100644 index 0000000000..57db9e3360 --- /dev/null +++ b/release-notes/studio/unreleased/14411-measure-preset-after-drag.md @@ -0,0 +1,2 @@ +- 修复:拖动过正文栏之后,在设置里选「铺满窗口」或「适宜阅读」不再没有反应;选了预设就以预设为准,丢掉之前拖出来的宽度。关联 #14411。 +- Fixed: choosing the full-width or reading preset in Settings did nothing once the column had been dragged; a preset now wins and drops the width you dragged. Refs #14411.