fix(frontend): let both static/dist readers see the link this module creates - #9062
fix(frontend): let both static/dist readers see the link this module creates#9062leonlaiyc wants to merge 2 commits into
Conversation
…creates `frontend._ensure_tree_dist` publishes `src/kiro_crew/static/dist` through `platform_compat.symlink_or_junction`, which falls back to a directory JUNCTION on Windows because a directory symlink there needs SeCreateSymbolicLinkPrivilege. So on an ordinary unelevated box KiroCrew itself puts a junction at that path. Two readers of the same path ask `is_symlink()`, which reports False for one. `pod.provision.build_dist` — the link check runs ahead of `is_dir()`/`is_file()` precisely so a DANGLING link is still replaced. A dangling junction answers False to all three (measured), so it fell through every branch and `shutil.copytree` landed on a directory entry that still existed: `FileExistsError [WinError 183]`, with no handler up the provisioning chain. `frontend._discard_path` — its own docstring explains that `rmtree` refuses a link even though `is_dir()` follows it, and then tests `is_symlink()`. A LIVE junction therefore reached the `rmtree` branch, whose refusal `ignore_errors=True` swallows, so the `.dist.previous.<pid>` entry was never reclaimed; a dangling one was not removed either. The caller `os.replace`s the served bundle onto that name, so a surviving entry makes the replace fail. Both routed through `is_link_or_junction` / `unlink_link_or_junction`. The symlink path is behaviour-preserving: `unlink_link_or_junction` calls `os.unlink` for a symlink, exactly what both sites did before. The unlink half matters as much as the predicate — a junction is a directory reparse point detached with `rmdir`, so the target's contents survive; reaching for `rmtree` once the junction is detected would delete through it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Kiro Crew` is two words outside identifiers, and the docstring added in the first commit joined them. The Fast Gate fails on it, and because the heavy matrix waits on the Fast Gate, every backend and frontend job on this PR was skipped rather than run — so this one word is what stood between the change and any test evidence at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed |
Design Review (Fable 5, fork) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS The fix is right, but the module has a third Watch
Suggestions
[DESIGN-REVIEWED] 261837a |
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of All claims in the description verified against the base tree; siblings counted. Final review: First-Principles-Verdict: CONCERNS Both fixes are real and mechanism-level, but the same module reads the same path with the same blind What this change shipsIntent: make provisioning and staging recover when the
Both hunks route through the existing canonical pair ( Watch
[FIRST-PRINCIPLES-REVIEWED] 261837a |
|
CI attribution for Both failing children ( The run checked out
Already repaired upstream. This branch touches Not rerunning and not rebasing: the next run against current |
Problem / Motivation
frontend._ensure_tree_distpublishessrc/kiro_crew/static/distitself:symlink_or_junctiontriesos.symlinkfirst and falls back to a directoryjunction on Windows, because a directory symlink there needs
SeCreateSymbolicLinkPrivilegethat an ordinary unelevated process does not hold.So on a normal Windows dev box, KiroCrew's own writer puts a junction at that
path — this is not a planted or adversarial shape, it is the product's default
output. Measured on an unelevated Windows shell:
Two readers of that same path ask
is_symlink(), which is False for a junction.1.
pod.provision.build_dist— the link check runs ahead ofis_dir()/is_file()on purpose, so a dangling link is still replaced (thesame ordering
frontend._stage_distuses, and for the same reason). A danglingjunction answers False to all three:
Every branch is skipped and
shutil.copytree(src_dist, dst)lands on a directoryentry that still exists. Measured:
FileExistsError: [WinError 183], and nothingup the provisioning chain handles it —
provision()and itspodcallers surfacea traceback instead of the
FATAL:line every other failure there produces.2.
frontend._discard_path— its own docstring already explains the mechanism:…and then tests
is_symlink(), 170 lines below thesymlink_or_junctioncall thatcreates the junction. A live junction therefore reaches the
rmtreebranch,whose refusal
ignore_errors=Trueswallows; a dangling one matches no branch atall. Either way the entry survives.
Why it matters
build_distis the crash:pod up/provision(build=True)on a checkout whosestatic/distlink has gone dangling — the linked tree was cleaned, or the worktreeit pointed into was removed — dies with an unhandled
FileExistsErrorrather thanrestaging. The identical situation with a symlink recovers cleanly, so this is a
Windows-only failure of an already-implemented recovery path.
_discard_pathis the leak, and it has a caller-visible tail:_stage_distcalls_discard_path(backup)to clear.dist.previous.<pid>and thenos.replaces theserved bundle onto that name. A surviving entry makes that replace fail, which the
except OSErrorturns into "Could not stage static/dist" — a refused publicationwhose cause is an entry that was supposed to have been reclaimed. The sweep over
.dist.previous.*at the end has the same blind spot, so the entries accumulate.Graded honestly: no data loss and no containment escape.
rmtreerefuses ajunction rather than deleting through it, so nothing behind the link is ever
destroyed. What breaks is availability of the provisioning/publication path.
What changed (motivation → approach → change)
Symptom: a junction at
static/distcrashesbuild_distand is never reclaimedby
_discard_path.Root cause: both readers spell "is this a link?" as
is_symlink(), which does notsee the junction this very module writes.
Change: both route through the repo's canonical pair.
The symlink path is behaviour-preserving:
unlink_link_or_junctioncallsos.unlinkfor a symlink — exactly what both sites already did — andrmdironlyfor a junction. The link branch stays first so the dangling case keeps working,
which is why it was written first in the original.
The unlink half matters as much as the predicate. The obvious "fix" once a
junction is detected — reach for
shutil.rmtree— would delete through it anddestroy the linked tree. Detaching with
rmdiris what keeps the target intact, andthat property is asserted rather than asserted-in-prose.
Both files already import
platform_compat; no import path grew. Nothing else ineither module is touched.
Why both sites in one PR: they are two readers of a single link, written by a
single call, and the fix is the same two lines. Fixing one would leave the other
looking at the same path with the same blind predicate.
Tests
test/test_pod.py(TestProvisionBuildPaths) andtest/test_frontend_edition_build.py, 5 tests:test_build_dist_restages_over_a_dangling_dist_linkFileExistsErrorcasetest_build_dist_still_short_circuits_on_a_LIVE_dist_linkhas_distand no build runstest_discard_path_detaches_a_live_dist_link_without_deleting_its_targettest_discard_path_removes_a_dangling_dist_linktest_discard_path_still_removes_a_real_tree_and_a_plain_fileEvery link is created with the product's own
platform_compat.symlink_or_junction,not a bare
os.symlink— so each test exercises whichever shape the runningplatform actually produces, and the Windows shards get the junction.
Guard-the-guard. Each test asserts the planted shape through oracles outside
the module under test before touching the code under test:
platform_compat.is_link_or_junction(...)is true, and for the dangling casesnot .is_dir()plusprov.has_dist(co) is False— without that last onebuild_distshort-circuits and nothing below would be under test at all.Red-before, measured.
build_dist, against unpatchedorigin/main:FileExistsError: [WinError 183]._discard_path, with the production hunkreverted and everything else in place:
2 failed(assert not True— the link wasstill there), the negative control still passing.
Control for pre-existing noise:
test_pod.py+test_frontend_edition_build.py+test_frontend_dist_resolve.pyreport the same4 failed on pristine
origin/mainas with the patch, and298 → 303 passed—exactly the 5 tests added. Those 4 are
cp950locale errors specific to thisWindows box, not a regression.
Gates on this head:
flake8clean,isort --check-onlyclean,mypy --platform linuxreports nothing in either production file, black and subprocess-encodinggates pass scoped
origin/main...HEAD(4 files).test_pod.pyis not in.github/black-baseline.txt, so it was checked withblackdirectly — the onlydelta was one line I had just added, and it was reformatted; the three baselined
files were deliberately left unformatted so no unrelated churn rides along.
Manual verification
N/A — unit coverage sufficient: both defects are decided entirely by which branch a
link matches, and the tests build real links with the product's own helper on the
affected platform rather than mocking the predicate.
Related Issues
None — found by inspection while auditing
is_symlink-based guards for the Windowsjunction blind spot, the same family as #7881.
Pattern harvest
Rule candidate:
semgrepPattern: a module that creates a directory link with
platform_compat.symlink_or_junctionand then reads that same path back withis_symlink()/os.path.islink(). The writer emits a junction on unelevatedWindows and the reader cannot see one, so a guard fails against the product's own
default output rather than against an adversary. Worth flagging mechanically:
symlink_or_junctionandis_symlinkappearing in one module is the signal, andfrontend.pyhad both, 170 lines apart.Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)Contribution License Agreement
🤖 Generated with Claude Code