Repository navigation
feat(editor): route the diff-view save through the guard (U8, #1375) - #1916
Open
easonLiangWorldedtech wants to merge 112 commits into
Open
easonLiangWorldedtech wants to merge 112 commits into
easonLiangWorldedtech wants to merge 112 commits into
Conversation
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR routes the interactive editor's save flows - the diff view's
saveChangesandsaveDirectly- through the guard, and fixes the ordering between a save and a teardown so a cancellation can never resurrect rolled-back content: when a teardown begins while a guarded publish is in flight, the rollback waits for the publish to settle and is provably the last writer;open()serializes with the previous session's finalization so a new edit session cannot start underneath an old cleanup.The diff view is the highest-traffic write path in the extension. This is the eighth of nine PRs for epic #1375 (chain tracked on #1991).
Related GitHub Issue
Tracking: issue 2023 (this unit's tracking issue; deliberately not a closing reference - the maintainer closes it when the series lands).
Refs: #1991 (chain tracking), #1375 (epic).
Description
What this PR changes:
saveChangespublishes throughguardedWriteand registers the in-flight publish; a teardown that begins during the publish is honored after the publish settles (the save reports no completed edit rather than claiming one).revertChangesclaims the cancellation first, then waits for the in-flight guarded publish before rolling back - the rollback is provably the last writer (verified with a barrier test).open()waits for a prior session's finalization or teardown before starting the new session, and the cancellation claim is cleared with the teardown marker on a clean start.saveDirectlyforwards a caller-supplied completeness flag through the direct publish, so a tool-level write can state whether the destination was fully observed.Why the diff looks bigger than the change: the chain is non-cumulative - this branch is based on
mainand carries its own copy of the earlier units' content (guarded-write core, observation registry, atomic publish, lock key), which the GitHub diff shows as context. This unit's own delta is 486 lines.Review focus:
src/integrations/editor/DiffViewProvider.ts(saveChanges, revertChanges, open) and the new tests insrc/integrations/editor/__tests__/DiffViewProvider.spec.tsandsrc/core/tools/__tests__/guardedWrite.spec.ts.Test Procedure
From the repository root, with dependencies installed:
Environment: Windows 11, Node 20, unit layer only.
Pre-Submission Checklist
.changesetor CHANGELOG changes (AGENTS.md).src/eslint-suppressions.jsonunchanged - no suppression count increased.--max-warnings=0) rather than relying on suppressions.Visual Snapshots
Not applicable - no rendered surface change; the diff view looks identical.
Videos (interaction / animation only)
Not applicable.
Documentation Updates
No user-facing documentation change: no new setting, no new surface, no model-facing text. No
.changesetand no CHANGELOG edit (AGENTS.md).Additional Notes
Verification at this head
DEFAULT_WRITE_DELAY_MSresolves from a stale junctioned dist; CI test and compile are green at this head). guardedWrite spec: 63 passed.src) exit 0; Prettier clean; suppressions unchanged.as anycasts in the new test exceeded the file's suppressed count; replaced with bracket notation and documented double casts, violation count back to the suppressed baseline).Updated this round
fe6ff685d -> 36e3f86b: fixes the open CodeRabbit Major (save-lifecycle race atDiffViewProvider.ts:1096). The publish gate released whenguardedWrite()settled, but a rejected save keeps working the same bytes and tabs afterwards: the adoption probe reads the disk and the discard cleanup reloads the buffer and closes the session's views. The whole rejected-save decision is now one registered pass (rejectedSaveCleanupInFlight) thatrevertChanges()waits on, and the discard cleanup recordsrejectedSaveCleanupHandledso a cancellation arriving after it finalizes the session without applying or savingoriginalContent(adoption-success rejections keep rollback-last). Regression tests: cancellation during the probe waits for the decision and keeps the rollback last; cancellation after the cleanup skips the rollback and finalizes exactly once. Negative controls: removing the gate await reddens only the probe test; removing the handled-session check reddens only the skip test.DiffViewProvider.tswere removed with this touch. Equivalence notes for the mutation gate: (1) thewriteKinddefault"update"- an empty kind dispatches exactly like"update"inguardedWrite(only"edit"and"create"branch distinctly); the default line itself is unchanged, so no mutant enters the changed-line selector. (2) thesaveTask &&operand in the adoption condition -GuardRejectedErroris only thrown byguardedWrite, which is reached only after the!saveTaskcheck has thrown a plain Error, so whenever the operand is evaluatedsaveTaskis provably non-null; the moved condition lines are killed by the existing adoption-rejection tests (non-guard failure with matching disk, dirty buffer with matching disk, external-writer match).6485f0636 -> fe6ff685dlint-budget fix for the spec casts (no behavior change); description rewritten for human reviewers and linked to issue 2023.Get in Touch
Reviewer and maintainer questions are welcome as comments on this PR or on #1991.
Re-review round (5a04426)
Cleared the three re-review threads:
notation for private members and documented structural doubles
(
as unknown as vscode.TextEditor/TextDocument); the spec'sno-explicit-anysuppression count drops 310 -> 285, no additions.counts matching
console.errorcalls (exactly 1); the weaker secondtoHaveBeenCalledWithand the stale "failed backup" comment are gone.Negative control: a duplicated report reddens exactly this test.
publishOverLinkthe backup stepnow
lstats the target and takes no content backup for a symboliclink: no referent bytes are copied, nothing is renamed back over the
link, and no copy is left beside it. Two regression tests (link: no
backup, commit lands; regular file: backup kept and cleaned).
Negative controls: guard disabled /
!== trueeach redden exactly thelink test; dropping the lstat operand reddens exactly the
regular-file test.
Equivalent mutant (no suppression, per AGENTS.md): dropping the
publishOverLinkoperand of the guard is equivalent, because withoutpublishOverLinkthe publish target is dereferenced byresolvePublishTargetand can never be a symbolic link - the lstatthen always reports a non-link.
DACL-over-link refusal (3624e4f)
Re-review follow-up: a captured-DACL publish whose target is a symbolic
link (
publishOverLink+lstatsymlink +daclDumpPathset) is nowrefused BEFORE the commit rename with a new
DaclPublishRefusedError(exported with the
DaclCaptureError/DaclRestoreErrorfamily, sameshape): the rename would replace the link itself and the DACL restore
would apply the saved ACEs to the wrong object. The error states the
target is unchanged; neither
/restorenor the restrictive fallbackruns.
DaclCaptureErrordoes not fit (capture succeeded);DaclRestoreErrordoes not (no rollback occurred).Regression test + negative controls: refusal removed -> exactly the new
test reddens; refusal unconditional -> exactly the no-DACL linux
symlink test reddens (that path still publishes with no backup).
The refusal also updates
safeWriteJson.lockKey.spec.ts(f23e49f):its two unscoped symlink publishes previously pinned the old win32
publish-over-link-with-ACL behavior; they now branch on
process.platform(win32:DaclPublishRefusedError, locks stillserialized,
/saveonly, link bytes untouched; linux: originalassertions unchanged).
Scope registration: the safeWriteJson carrier on this unit
The confinement, symlink lock-selection and platform-DACL expectations
in
src/utils/safeWriteJson.tsand its tests are deliberate on thisunit, not scope drift. The guarded-write chain's unit map - tracking
issue 1991 - assigns this carrier here: its section "Port source:
folding a lock key the way the lock is placed (owed on U8, PR 1916)"
records the lock-key folding derived from U6 (PR 1915, commit
5131bc0) into U8 (this PR, commit 19aec00), including the owed
mixed-ancestor acceptance test; and the ownership ruling in the same
issue keeps primitive-level ports on the unit whose branch carries
that version of the primitive ("Inherited by any unit whose branch
carries this version of the primitive - U1 and U8 among them").
Removing the carrier from this PR would strand the port the tracking
issue owes on U8.
Walkthrough rows 1-4 (c6b3e86)
1. Out of Scope - sharpened pre-emption. The proposed resolution
("move the
safeWriteJsonchanges to the file-safety unit") wouldbreak the owed-port chain, not restore scope: the
safeWriteJsonlock-key folding ON THIS BRANCH IS the port - tracking issue 1991,
section "Port source: folding a lock key the way the lock is placed
(owed on U8, PR 1916)", records it derived from U6 (PR 1915, commit
5131bc0) into U8 (this PR, commit 19aec00), with the owed
mixed-ancestor acceptance test tracked there. The file-safety unit
(U5, PR 1914) is the port SOURCE and merges before this unit in the
declared order; moving this carrier out of U8 strands the port the
tracking issue owes on U8 and deletes the derivation record's target.
Scope is deliberate per the unit map; no move.
2. Security Boundaries - standing TOCTOU window, registered. The
resolve/lock/version-check alias race in
replaceIfVersionis thestanding confinement-primitive window, not a defect introduced here:
the check and the publish already run inside ONE acquisition of the
shared advisory lock keyed by
resolveLockKey(a symlink alias andits referent share one lock), and the primitive's docstring states the
atomicity boundary and the no-lock writer explicitly. Closing the
resolve-to-lock alias swap is the directory-identity pinning that is
U5's (PR 1914) content, which merges BEFORE this unit in the declared
order - the row resolves at merge time. Same family as the TOCTOU row
registered on PR 1918 (see record 6106959976 on 1408) and the
primitive-level ruling in tracking issue 1991.
3. Regression Evidence - fixed in c6b3e86. New spec
core/task/__tests__/Task.observationRegistry.spec.tsconstructs twoREAL Task instances (same construction path as
Task.dispose.test.ts,startTask: false) and asserts both exposeObservationRegistry, are NOT the same object, and that anobservation recorded on one is absent from the other. Negative
control: swapping the field initializer for a module-shared registry
reddens exactly this test (1 failed | 13 passed across the two
registry specs). The existing unit spec is untouched. 14/14 green,
eslint/prettier/tsc clean.
4. Lifecycle warning - registered, HOLD family. The
open()/reset()double-await race is the teardown-waiter familyrecorded in tracking issue 1991, record 8 ("U9 teardown: the waiter
cannot tell whether the pass it waited for succeeded (PR 1917 / PR
1918 - fix once and port)"). The fix belongs to the U9 teardown
content and is owed there once; the carried copies on this unit
resolve at merge time in the declared order. Not a U8-level defect
per the same primitive-level ruling.
Preview-withdrawal port (82cfbf2)
The sibling unit's Major finding (preview observation surviving on the
non-edit exit paths) applies identically to the DiffViewProvider copy
this unit owns, so the fix is ported here as well (this unit merges
first):
open()records the token its preview wrote;performFinalReset()- the single execution point behindreset()-forgets the entry while it still holds that token and the preview's
incomplete flag. Trigger-sequence test + two survival tests ported;
negative control: disabling the withdrawal reddens exactly the trigger
test. 166/166 green.
Ownership-based withdrawal (a00c5b7)
The withdrawal now tracks the exact entry object the preview wrote
(
{ path, entry }identity) instead of version+completeness: a modelpartial read while the diff is open replaces the entry with a fresh
object at the same token and flag, and survives
reset(). Regressiontest + two NCs (each exactly one red). 167/167 green. Ported to the
sibling unit's identical copy (14f8634).
Re-review responses (error-fix re-review wave)
guardedWrite(Major Data-Integrity): theguard now resolves with the token it read back under its lock;
saveChanges()records it as the rollback baseline and the comparisonprefers it over the create placeholder, so a session's own publish is never
mistaken for a peer write and a peer write is never silently overwritten.
New end-to-end spec case (real save -> peer stat change -> rollback
conflict); the two chain tests that pinned the old void contract now assert
the returned token.
open()andperformFinalReset()clear the rollback baseline with the other sessionfields; a reused provider cannot inherit the previous session's token and
report a false conflict. Two regression tests added.
rollback restores the evicted preview tabs and claims the finalization
before the conflict propagates; the peer-publish test now asserts the
session is finalized after the rejection.
Rollback publishes through a version-bound compare-and-swap (4e4cf7f)
The Persistence Integrity row: the existing-file rollback checked the
disk token and then applied the buffer through VS Code's save path - the
check was not bound to the publish, so a peer write landing after the
check was silently overwritten by originalContent. The rollback now
publishes the original bytes through
replaceIfVersion: the versioncheck and the publish happen inside one acquisition of the shared
advisory lock, with
lastWrittenTokenas the expected version. A CASrejection preserves the peer's bytes and raises
RollbackConflictErrornaming the disk token it found; on success the buffer is reconciled by
reverting onto the published bytes instead of saving over them. Where
either side of the comparison is missing (never published, or no disk
version evidence) there is nothing to bind the publish to and the save
fallback remains, documented as such.
The create branch no longer saves the dirty diff buffer before the
delete - that save published the cancelled edit's content to disk for a
moment, unbound to the placeholder check - and unlinks only after the
placeholder identity/token check inside the lock.
Tests: the barrier test pauses the rollback's version read, lands a peer
publish inside the pause, and verifies the publish rejects with the
peer's bytes intact and the buffer untouched; a new test pins that the
create rollback deletes without saving first. Negative controls:
replacing the CAS with an unguarded publish reddens exactly the four
tests that pin the rejection contract; removing the buffer reconcile and
re-adding the pre-delete save each redden exactly one test. 174/174
green at this head.
Rollback evidence contract (096abc2)
The 05:51Z Persistence Integrity row implemented in full: every existing-file rollback publishes the original bytes through the atomic compare-and-swap; when the session baseline or the current disk version is unavailable the rollback preserves the disk state and raises
RollbackConflictErrorinstead of saving (the unguardedupdatedDocument.save()fallback is removed); the create rollback unlinks only on a proven token match under the file lock - unknown ownership (identity-only or failed stat-bracketed open) or a mismatch preserves the path and the created directories and reports the conflict. The buffer is reconciled by reverting onto the CAS-published bytes. Tests: missing-baseline and missing-disk-evidence rollbacks, identity-only create peer replacement, plus the tab-close/ordering tests updated to the revert contract. 177/177 green; three negative controls pin the contract (evidence gate, create ownership, revert-vs-save).The Security Boundaries alias row remains the registered ApplyDiffTool alias family (lands once at the publish-primitive owner; precedent record 6106959976, issue #1408).
Allowlist binding to the retained target (bf09481)
The 08:21Z Security Boundaries row implemented in full:
replaceIfVersionandcreateIfAbsenttake anauthorizeTargetcallback run under the lock on the RETAINED publish target before the version comparison and the publish; the guarded-write entry re-runsrooIgnoreController.validateAccesson the retained target, so a retargeted symlink or hard-link alias cannot receive approved bytes in an ignored/external file. Both identity locks (referent key + alias-entry key viaresolveLinkPathLockKey) are taken in the shared sorted order when distinct. Tests: denial before version comparison, retained-target binding, end-to-end alias-passes/referent-ignored rejection, sorted dual-key acquisition, single-lock fold; four negative controls pin each element. 62/62 guardedWrite, 239/239 with the diff-view suites. The cited retained-target block exists only in this unit's copy; no U-family port applies.The Regression Evidence row (safeWriteJson multi-lock cleanup test) remains carrier content: the test belongs to the safeWriteJson owner unit (scope registration above).