Compare commits

..
Author SHA1 Message Date
anandh8x 72440a85bf fix(tui): align active voice status with capture key 2026-08-26 16:25:15 +05:30
anandh8x ed38f57fab fix(tui): clarify voice capture controls 2026-08-26 15:38:35 +05:30
anandh8x 7e6f5e2a4a fix(tui): keep voice mode from swallowing spaces 2026-08-26 14:54:23 +05:30
PierrunoYTandClaude Opus 5 27b319ca88 test(execution): fail loudly when ps cannot answer instead of skipping (#961)
* test(execution): fail loudly when ps cannot answer instead of skipping

Every test in process_unix_test.go proves something about zombie detection, and
every one of those proofs is read out of ps. When ps could not answer, the
tests skipped — which is indistinguishable from success in CI output — and one
assertion passed vacuously for the same reason. A regression in ps parsing or
availability would have shown up as green.

requireProcessState probes BOTH ps pipelines against this test process, which
is by definition alive and not a zombie, so a working ps must report a live
state for it. When it cannot:

- Under CI (or ZERO_REQUIRE_PS=1) this is a hard failure. ps is present on every
  image used today, so its absence means the pipeline regressed and that has to
  surface immediately.
- Otherwise it stays a skip, so a contributor on an unsupported box is not
  blocked.

Both pipelines are probed because they are deliberately separate: the tests
assert what the production process-table parser concludes, so this file keeps
its own per-PID ps oracle rather than borrowing that parser to establish its own
preconditions. One regression must not be able to silence both sides at once.

Once the probe passes, failing to observe an EXPECTED state is a defect rather
than an environment problem, so the remaining escapes become failures:

- The zombie waits (awaitZombie) fail instead of skipping. The caller never
  waits on the child, so its zombie row persists until reaped — reaching the
  deadline means the child never exited or ps stopped answering, and the two
  are reported distinctly.
- A zombie group that is not signalable fails instead of skipping: a zombie
  still occupies the process table, so if kill(-pid, 0) fails the case under
  test is not present and the assertions after it would prove nothing.
- processSignalTarget returning anything but the individual PID fails instead
  of skipping: the child inherited this process's group, so its PGID is this
  process's PID and can never equal its own PID.

The vacuous assertion in TestTerminateProcessTreeStopsRunningGroup now requires
ok. That is sound rather than merely stricter: the test never waits on the
child, so the group's zombie row must still be present after termination, and
an unknown state means ps stopped answering rather than that the group is
clean.

Verified on Linux (WSL, go1.26.6) rather than by cross-compilation alone:

- all three tests pass unmodified
- with a stub ps that exits 1: CI unset skips with the reason named; CI=true
  fails, naming which pipeline could not answer
- the same broken-ps run against the ORIGINAL file produced two silent skips
  plus one unrelated failure after 12s, versus an immediate, explicit failure now

Windows is unaffected (the file is !windows); go vet passes for linux, darwin
and windows.

Fixes #863

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01U389qmQUhoZB3YtXkFmiTq

* test(execution): treat an unsignalable zombie group as a kernel difference

The macOS Smoke job failed:

    the zombie group 5931 is not signalable (operation not permitted)

That was my error. I converted this skip along with the ps-availability ones,
but it never had anything to do with ps: whether kill(2) may signal a group
whose only member is a zombie is a KERNEL difference. Linux permits it, so
signalTargetRunning goes on to consult ps — the branch this test was written
for. Darwin returns EPERM, so signalTargetRunning short-circuits on the kill and
never reaches ps.

Rather than restore the blanket skip, the test now records which branch answers:

- Signal permitted: assert processGroupHasRunningMember directly, so a ps-side
  regression cannot hide behind the kill result. This is new coverage; the old
  test only checked signalTargetRunning, which conflates the two paths.
- Signal refused: log it and continue.

The outcome assertion — a zombie-only group must not count as running — now runs
on EVERY platform. Previously Darwin skipped before reaching it, so this is
strictly more coverage there, not less.

Any errno other than EPERM still fails: it means the group is not in the state
the test needs, and nothing after it would be meaningful.

TerminateProcessTree is asserted only where the signal is permitted, because
terminateTarget's own kill(2) would hit the same EPERM and return it as a
cleanup error. Worth a maintainer's eye: that may be a real gap in
TerminateProcessTree on such a platform — a zombie-only tree is already exited
and arguably should terminate cleanly rather than report failure, which is the
same spurious-cleanup-failure class #774 set out to remove. Confirming that
needs a Darwin host and is a production change, so it is flagged rather than
guessed at here.

Verified on Linux (WSL, go1.26.6): all three tests pass, including the new
direct ps assertion. The Darwin branch was exercised by forcing the EPERM path
on Linux — it logs both explanations and passes with the outcome assertion
still enforced. go vet passes for linux, darwin and windows.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01U389qmQUhoZB3YtXkFmiTq

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-08-26 13:22:43 +08:00
Anandan 1fde941803 perf: reduce startup and turn overhead (#932)
* perf(tui): disable duplicate composer cursor blink

* perf(tui): stop composer wakeups after idle

* refactor(tools): publish immutable registry snapshots

* perf(startup): defer optional tool discovery

* perf(provider): reuse compatible response sessions

* feat(provider): add native patch tool input

* fix(provider): recognize saved chat sessions

* perf(tui): render only on screen changes

* fix(runtime): address performance review findings

* fix(usage): expose cache efficiency details

* perf(agent): reduce redundant bounded-task turns

* fix: harden runtime review edge cases

* fix: address runtime review findings

* build: restore official Bubble Tea dependency

* fix(cli): redact MCP registration errors

* fix: harden turn session regressions

* fix: keep run tools and patch roots consistent

* fix: bind freeform patch writes to scoped roots

* test: keep patch fixtures out of worktree

* fix(tools): reject mixed-root freeform patches
2026-08-26 13:22:05 +08:00
KRATOS 7acac7acee fix(sandbox): refcount temporary roots so one holder's cleanup cannot revoke another's (#911)
* fix(sandbox): a temporary root is refcounted, so one holder's cleanup cannot revoke another's

Split out of #829 as an independent fix, per @Vasanthdev2004's review asking for
the self-contained pieces to arrive separately.

A temporary grant was add-then-remove with no notion of who still needed the
root. The second caller to ask for a root already present got a NO-OP undo, and
the first caller's cleanup then removed the root out from under it — so a live
grant lost its access while its holder still believed it had it.

Two read-only tools in the same parallel batch, both blocked on the same
directory, is exactly that shape, and read-only tools are precisely the ones the
batch runs concurrently.

tempReads/tempWrites now count live holders, and the root is only stripped when
the last one releases.

The release also performs both mutations under ONE hold of the lock. Dropping it
between them left a window where the root was still in readRoots but no longer in
tempReads: a concurrent AddTemporaryRead landing there reads it as a PERMANENT
root, hands its caller a no-op undo, and this call then strips the root — so that
caller believes it holds access it has already silently lost.

Independent of the remaining #829 work; builds and tests against current main on
its own.

Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc

* fix(sandbox): a read covered by a TEMPORARY write root takes a reference too

Found by a self-review of this branch: the refcount closed the defect within
reads and within writes, but not across them.

AddTemporaryRead returned a no-op undo whenever writeRootCoversLocked said a
write root already covered the path, and the comment there claimed that cover was
permanent. It is not: writeRootCoversLocked scans extraRoots, and
AddTemporaryWrite appends TEMPORARY write roots into that same slice. So a read
covered by another holder's temporary write took no reference, and that holder's
cleanup silently revoked the reader's access — the very defect the refcount
exists to close, reached across the read/write boundary instead of within one
side of it.

Demonstrated before fixing, and the regression fails without it:

  ReadRoots before release = [ws, …/outer]
  ReadRoots after  release = [ws]
  the write holder's cleanup revoked a live read grant on …/outer/inner

The read now takes a reference on the covering temporary write root, and the
genuinely permanent case — the workspace root, or a session-scoped grant — still
returns a no-op undo.

The regression also asserts the other direction: once the reader releases too,
the root is gone, so the reference does not leak the grant past its last holder.

Origin-Session: local-0484ba | Claude Code | 6 prompts
Origin-Snapshot: e6d90ff3ce46
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc

* test(sandbox): make the concurrency test concurrent, and portable off macOS

Both raised by CodeRabbit. The third finding on this PR — a read covered by a
TEMPORARY write root taking no reference — was already fixed in 87a722cb with the
regression it asked for; this covers the two that were still live, and both are
cases of a test reporting more than it checked.

THE CONCURRENCY TEST WAS NOT CONCURRENT. The gate spun until hasReadRoot saw
somebody holding the root, which is satisfied by the FIRST holder, then opened
the release. The spin wins that race against goroutines still being scheduled:
instrumented over 200 runs, 194 peaked at a single simultaneous holder and not
one ever reached eight. A test named for concurrent holders was exercising one
holder at a time. Against a deliberately broken refcount, where the first release
strips the root, it passed 33 times in 40 — it was a flaky test that mostly
agreed with broken code. Counting all eight acquisitions makes the overlap real:
200 of 200 runs now peak at eight, and the same broken refcount is caught 40
times in 40. Holders signal the barrier on every path including failure, so a
holder that returns early fails the test rather than hanging it.

THE HELPER SKIPPED ON WINDOWS. scopeOutsideRoots reached for /Users/Shared and
fell back to /var/empty, neither of which exists there, so every test built on it
skipped and reported green having asserted nothing. It now uses the home
directory, which no platform lists as a default write root, matching what
scope_extra_read_test.go already does — and then PROVES the choice against
defaultTempWriteRoots rather than assuming it, so a future default root turns
these tests into an honest skip instead of a silent no-op.

Origin-Session: local-abff1c | Claude Code | 2 prompts
Origin-Snapshot: d2f269b81f33
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc

* fix(sandbox): a permanent grant outlives the temporary one, and an undo is per holder

Both P1s from @anandh8x, both reproduced before changing anything, and both are
the same mistake in different places: treating "something covers this path" as
though it answered "and will it still be there afterwards".

A PERMANENT GRANT WAS REVOKED BY A TEMPORARY HOLDER. extraRoots holds temporary
write roots alongside permanent ones, so Add asked only whether the path was
already covered. A session-scoped grant made while a temporary holder happened to
cover it recorded nothing at all, and disappeared the moment that holder
released. Outliving the request that prompted it is the entire difference between
Add and AddTemporaryWrite, and it was not happening. AddRead had it too,
including across the boundary: a permanent read covered only by a temporary WRITE
root died with that root.

Permanent coverage is now looked for first and across every root, so a temporary
cover cannot decide the answer when a permanent one is also present. The same
root held temporarily is promoted in place — its holders' releases become no-ops,
which is what permanence means here. A BROADER temporary root over a narrower
permanent grant cannot be fixed by promotion, so the narrower root is recorded in
its own right; that is the case he specifically called out, and it is the one a
promotion-only fix would have missed.

ONE HOLDER'S UNDO IS ONE HOLDER'S REFERENCE. The comment on releaseTemporaryRead
claimed each undo is called exactly once and that flooring the count at zero
covered the rest. Flooring stops the count going negative; it does nothing about
a holder's second call consuming a DIFFERENT holder's reference. With two
readers, calling the first's undo twice took the count 2 -> 1 -> 0 and removed a
root the second was still using — the exact revocation this refcount exists to
prevent, reached through a duplicate call rather than a sibling's cleanup, and a
deferred cleanup in a retry path is how a caller does that by accident. Every
undo handed out is now wrapped so it drops its own reference at most once, and
the comment says what is actually true.

Both mutation-checked: unwrapping the undo, and ignoring permanence in Add, each
fail the test that covers them. Race detector clean.

Origin-Session: local-abff1c | Claude Code | 3 prompts
Origin-Snapshot: 07900397c62e
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc

* chore(sandbox): re-request an automated review of this head

No code change. CodeRabbit's verdict on this PR is pinned to ff85dbcc, a commit
that no longer exists on the branch, and a stale changes-requested keeps the PR
at mergeStateStatus=BLOCKED exactly as a human one would. It re-reviews on push
rather than on request, and repeated @coderabbitai triggers over five days
produced nothing, so this is the only lever available without maintainer rights.

The cost is that this dismisses @Vasanthdev2004's and @anandh8x's approvals of
4a7063aa. The tree they approved is byte-identical to this one.

Origin-Session: local-8cd239 | Claude Code | 6 prompts
Origin-Snapshot: 149ab98453ad
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc

* test(sandbox): a fixture failure fails, a report cannot deadlock, and AddRead's
own branches are covered

All raised by CodeRabbit on the full review. Every one verified before changing
anything.

## The concurrent test could hang instead of failing

failures was buffered at one slot per holder, but each goroutine can report at
BOTH hasReadRoot checks — its own grant going missing while held, and again
after the release gate. Nothing drains the channel until after wg.Wait(), so the
ninth report blocks forever, wg.Done() is never reached and the wait hangs. The
test would deadlock in exactly the case it exists to catch: a refcount bug
severe enough to fail more than eight of eight holders. Buffered at 2*holders.

## A broken fixture reported itself as a pass

TestANarrowerRequestHoldsTheCoveringGrant called t.Skip when os.MkdirAll failed.
The directory is the fixture, not a precondition the environment may reasonably
withhold, and the sibling test above it already uses t.Fatal for the same call.

## Two tests assumed something they never checked

Both tests in scope_extra_read_test.go place their grant under $HOME precisely
because NewScope seeds /tmp and $TMPDIR as write roots — a grant that collapsed
into one of those would be writable for a reason unrelated to the property under
test. $HOME is normally clear of them, but a sandboxed or CI environment that
points HOME at a temp directory turns both tests into something that proves
nothing while still reporting PASS.

homeGrantOutsideTempRoots now builds the fixture for both and skips with the
reason when the assumption does not hold. Verified by running with HOME set to a
temp directory: both tests SKIP naming the covering root, where before they
passed. The path is resolved before comparing, because the temp roots are
resolved and /var is a symlink to /private/var on macOS.

## AddRead's branches were only covered from the write side

Two subtests added: a permanent read under a temporary WRITE root, and a narrow
permanent read under a BROAD temporary read root. Both mutation-checked by
removing the corresponding temporary-coverage guard in AddRead — each fails.

The first mutation I ran did not fail its test, and the finding looked wrong: the
pattern being edited occurs in Add, AddRead and AddTemporaryWrite alike, and a
first-match edit had changed Add. Re-run against the line inside AddRead, it
fails as claimed. Recorded because a mutation that misses its target is
indistinguishable from a test that holds.

## Also asserted: the refcount table is empty, not merely invisible

hasReadRoot answers what a caller can see. An entry left at zero in tempReads is
not visible that way and would still leak one map entry per acquire/release
cycle, so the contention test now checks the terminal state directly.

go test -race ./internal/sandbox/ -count=5: clean.
Unrelated and pre-existing here: TestRunDoctorFormatsRedactedProviderDiagnostics
and TestRunDoctorConnectivityProbesProvider exit 3 in this environment.

Origin-Session: local-8cd239 | Claude Code | 12 prompts
Origin-Snapshot: dd397730a138
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc

* fix(sandbox): a reader outliving its writer keeps read authority, not write

Reported by @jatmn, and it is a privilege escalation rather than a lifetime bug.

AddTemporaryRead, given a path already covered by a temporary WRITE root, took a
reference on that write root and returned an undo that released it. The lifetime
was then correct — the reader no longer lost its access when the writer cleaned
up — but the capability was wrong. extraRoots feeds validate(), which authorises
WRITES, so once the write holder released first the path stayed in extraRoots for
as long as the read holder lived. A caller that asked only to READ could write
anywhere below the former write root after the write grant had ended.

His diagnosis names the root cause exactly: one write-capability reference was
doing two jobs, tracking a lifetime and standing in for a read-only dependent.

The lifetime is now kept as what the caller actually asked for — a temporary READ
root on its own path. AddTemporaryRead consults permanentWriteRootCoversLocked
rather than writeRootCoversLocked, so a merely temporary write cover no longer
short-circuits it into borrowing write authority.

Mutation-checked: restoring writeRootCoversLocked in that branch compiles and
fails the regression on three separate assertions — the reader keeps no read root
of its own, ReadRoots() loses the path, and the writer's cleanup revokes a live
read grant.

Two things a verification pass turned up that are worth recording:

The comment justifying this design said readRoots "feeds ReadRoots() and nothing
else". That was false — it also feeds ExtraReadRoots(). The security argument
survives, because neither confers write authority and validate() reads
workspaceRoot plus extraRoots, which this path never enters. But that sentence IS
the written justification for the whole choice, so it now lists both and says why
neither grants write.

The "the reader releases first" subtest passes on the unfixed tree: the
escalation needs the WRITER to go first. It is a real no-regression control for
the opposite ordering, not evidence the finding is closed, and it is renamed to
say so rather than padding the count.

Rebased onto ad34dc8d as requested. go test -race ./internal/sandbox/ -count=5:
clean. Pre-existing here and on main, unrelated:
TestRunDoctorFormatsRedactedProviderDiagnostics and
TestRunDoctorConnectivityProbesProvider exit 3 in this environment.

Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc

* fix(sandbox): a nested write holder keeps only the subtree it asked for

Reported by CodeRabbit, and it is the write-side twin of the read-side
escalation @jatmn found — I had disclosed it on this PR as still open, so this
closes it rather than deferring it again.

AddTemporaryWrite, given a path already covered by a broader TEMPORARY write
root, took a reference on that covering root. The lifetime was right and the
capability was wrong, exactly as on the read side: extraRoots is what validate()
authorises writes from, so keeping the covering root alive handed the nested
holder everything its neighbour had asked for.

Reproduced:

  both held           tempWrites={outer:2}                extraRoots=[... outer]
  after outer release tempWrites={outer:1}                extraRoots=[... outer]
  -> the inner-only holder can WRITE outer/sibling/not-mine.txt

A narrower request now records its own root. Each holder gets exactly the
authority it asked for and a lifetime of its own:

  both held           tempWrites={outer:1, inner:1}       extraRoots=[... outer inner]
  after outer release tempWrites={inner:1}                extraRoots=[... inner]
  -> sibling denied, inner retained

Only an EXACT match shares a refcount entry; a narrower one never does, which is
the distinction the escalation turned on. The nesting costs a redundant
extraRoots entry while both holders are live, which is not wrong — the broader
root already permits everything the narrower one does.

Three regressions: the escalation itself (broad holder releases first), the
opposite order as a control, and two holders of the same root sharing one entry.

Mutation: restoring the covering-root borrow compiles and fails the escalation
test on both the sibling and the covering root itself.

Honest note on a second mutation. Removing the permanent-cover early return
compiles and fails NOTHING. That is correct rather than a coverage gap: a
permanently covered request would then record its own temporary root, which is
redundant but harmless, since the permanent root outlives it and already permits
the writes. The early return is a cleanliness guard, not a correctness one, and
I would rather say so than write a test that pretends otherwise.

go test -race ./internal/sandbox/ -count=5: clean. Pre-existing here and on main:
TestRunDoctorFormatsRedactedProviderDiagnostics and
TestRunDoctorConnectivityProbesProvider exit 3 in this environment.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b

* test(sandbox): the outside-roots fixture fails loudly instead of skipping

Raised by CodeRabbit, and the helper had already written the argument against
itself: its own comment says "A skip is not a pass" while three of its four exits
were skips.

Every caller of scopeOutsideRoots asserts an AUTHORIZATION boundary — who may
write where, and whose release revokes it. A skipped fixture means that assertion
never ran and the build reported green anyway, which is precisely the failure the
helper was written to close. It closed it for one case (a home directory that
resolves nowhere on Windows) and left the others open.

Three exits become fatal:

  os.UserHomeDir failing        — resolves on every platform Zero supports
  os.MkdirTemp(home) failing    — a home that will not take a temp directory is a
                                  broken machine, not a limited one
  os.MkdirAll under base        — base was just created successfully, so a
                                  subdirectory failing under it is the same

If any fires, the right outcome is a red build somebody fixes, not silent
non-coverage of the sandbox's write rules.

ONE SKIP STAYS, and it is a different kind. When the grant path is already inside
a default write root, the fixture built correctly and the machine is fine — the
grant simply cannot demonstrate anything, because that path is writable before
any temporary grant exists. Running on would assert a permission that was never
in question. The comment now says which kind it is, so the distinction survives
the next reader.

Verified the fatal path fires: forcing the MkdirTemp error compiles and fails
TestANestedWriteHolderKeepsOnlyItsOwnSubtree at the fixture with the reason,
where it previously reported a pass.

go test -race ./internal/sandbox/ -count=3: clean. Pre-existing here and on main:
TestRunDoctorFormatsRedactedProviderDiagnostics and
TestRunDoctorConnectivityProbesProvider exit 3 in this environment.

Origin-Session: local-c962d7 | Claude Code | 13 prompts
Origin-Snapshot: a599377c09e0

* fix(sandbox): least authority for nested readers, and a hermetic grant fixture

@jatmn's three findings, taken as one pass over the grant construct rather than
as three local patches — his point that the same invariant kept not being
reapplied to the symmetric branch was fair, and this is the symmetric branch.

## The nested reader kept its neighbour's tree

AddTemporaryRead had exactly the shape AddTemporaryWrite was fixed for one
commit earlier: reader B asking for outer/inner took a reference on reader A's
covering root. Lifetime correct, capability wrong. readRoots feeds validateRead
and the native sandbox profile, so once A released, B could read outer/sibling.

Reproduced: tempReads={outer:2}; after A releases, the inner-only reader reads a
sibling it never asked for.

The rule is now the same on both sides: permanent coverage decides first, an
EXACT temporary root shares one refcount, and a narrower request records its own
root. Mutation: restoring the borrow fires both escalation assertions.

## Slice order no longer decides permanent coverage

permanentReadRootCoversLocked asks the whole question over both lists before any
insertion order can answer it, and AddRead now uses the same predicate instead of
its own scan, so the two cannot drift.

Honest note: with the borrow gone, this mutation is no longer catchable. Making
the scan return at the first covering entry compiles and fails nothing, because
AddTemporaryRead no longer borrows anything — it either no-ops or records its own
root, and neither escalates. The guard now prevents a redundant refcount entry
rather than an escalation. Verified by measurement, not assumed.

## A test that asserted the defect

TestANarrowerRequestHoldsTheCoveringGrant proved a lifetime property by
asserting the BROAD root was still present after the broad holder released —
which is the escalation written down as a requirement. It is replaced by tests
that assert lifetime and authority separately: the requested path stays readable,
the sibling and the covering root do not, plus the reverse release order,
exact-root sharing, and a permanent narrow root not borrowing a broad temporary
one.

## The obsolete predicate that broke CI

writeRootCoversLocked lost its last caller when the three coverage decisions
moved to permanentWriteRootCoversLocked and exact-root logic. make lint-static
reproduced the Windows `unused` failure locally. Removed rather than suppressed.
This is on me: I did not run lint-static before the previous push, which is a
required step, and CI caught what I should have.

## Fixtures out of the real home

scopeOutsideRoots and homeGrantOutsideTempRoots both built fixtures under
os.UserHomeDir(). Fixed at the cause jatmn identified rather than by relaxing the
setup checks: NewScope seeds the system temp directory as a permanent write root,
which is what made a t.TempDir() grant vacuous and sent the fixture to HOME in the
first place. Both helpers now build the Scope DIRECTLY — the pattern
scopeOutsideDefaults and temporaryWriteFixture already used — so no defaults are
seeded, t.TempDir() is outside by construction, and nothing touches HOME.

Each helper now asserts the NEGATIVE against its own scope before returning: the
target must be unwritable, and unreadable where relevant, before any grant
exists. That is stronger than the old check against defaultTempWriteRoots(),
which asked about a scope these tests no longer build.

Verified with HOME readable but not writable (caches redirected): the sandbox
suite passes. The one remaining failure there is
TestSeatbeltEnforcesExtraWriteRoots in seatbelt_integration_darwin_test.go, which
is NOT in this PR's diff — pre-existing, same shape, and worth its own issue
rather than riding along here.

gofmt, go vet, lint-static (0 issues), go test -race ./internal/sandbox/ -count=5:
all clean. Pre-existing here and on main:
TestRunDoctorFormatsRedactedProviderDiagnostics and
TestRunDoctorConnectivityProbesProvider exit 3 in this environment.

Origin-Session: local-c962d7 | Claude Code | 14 prompts
Origin-Snapshot: a599377c09e0

* refactor(sandbox): drop the child-grant accessors this PR does not consume

Reported by @jatmn.

ExtraRoots and ExtraReadRoots have no production caller anywhere in the tree —
only their own definitions and this PR's tests. They arrived with the initial
split because the extraction was made at the file level: the temporary-root
implementation came across while the CLI and runner wiring that consumes them
stayed behind in #829's child read-grant propagation area. That leaves unrelated
production surface in a branch scoped to refcounting.

Both accessors are removed, along with the getter-only propagation assertion that
existed to exercise them. The refcount regressions that used ExtraRoots only to
inspect beyond-workspace write state now use a test-only helper over Roots(),
because that inspection is a test concern rather than an API this branch needs.

Nothing about the refcount, permanence, least-authority, atomic-release or
hermetic-fixture work changes, and no child propagation is wired here — that
would widen the scope rather than fix the extraction boundary.

One comment needed correcting rather than deleting. The justification for keeping
a reader's lifetime as a READ root named the lists readRoots feeds, and that
sentence has now been wrong in both directions: an earlier draft said "ReadRoots
and nothing else" while ExtraReadRoots also read it, and the accessor it was
corrected to mention is the one being removed here. It now describes what ships
and says why the list has to stay exhaustive as accessors change.

Rebased onto 6fe0d1ed, 0 behind. lint-static: 0 issues.
go test -race ./internal/sandbox/ -count=3: clean. Pre-existing here and on main:
TestRunDoctorFormatsRedactedProviderDiagnostics and
TestRunDoctorConnectivityProbesProvider exit 3 in this environment.
2026-08-26 13:21:25 +08:00
KRATOS 1649008506 fix(tools): an edit keeps the reads it did not disturb (#908)
* fix(tools): an edit keeps the reads it did not disturb

Split out of #829 as an independent fix, per @Vasanthdev2004's review asking for
the small self-contained pieces to arrive separately.

A successful edit re-baselined the file through RecordHash, which drops EVERY
recorded read range. A file read in three pieces therefore lost all three to a
single two-line edit, and the next six edits into regions that had already been
read were refused as unseen — the model had to re-read what it had just read in
order to keep working.

RecordEdit replaces that on the edit path. Because the edit is ours, the changed
span is known exactly, so ranges the edit did not touch are carried across,
ranges after it are shifted by the line delta, and a range the edit spans is
split around it.

An EXTERNAL change still drops everything, deliberately: once a formatter has
rewritten the file we no longer know which line holds what was read, and the
conservative drop is the only honest answer there.

"Seen whole" is now decided in ONE place. SeenWhole derives the answer from the
ranges because the raw flag is only ever set by a single covering read, but
RecordEdit branched on the flag — so a file read whole in two chunks reported
seen-whole true, then false after one edit, putting back the write_file refusal
the derived answer exists to prevent.

countLines now reports the count a READER would give. strings.Split leaves an
empty final element for content ending in a newline, so it counted 4 lines for a
3-line file; since SeenWhole asks whether the ranges cover 1..total, that
inflated total made full coverage unreachable for a file just read in full.
Nearly every text file ends in a newline, so this was the common case.

Origin-Session: local-de382f | Claude Code | 9 prompts
Origin-Snapshot: 92ae33c95cd1
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: ae653221fa15

* chore(tools): re-request an automated review of this head

No code change. CodeRabbit's verdict on this PR is pinned to 6f0cd6c4, a commit
that was force-pushed away five days ago, and a stale changes-requested keeps the
PR at mergeStateStatus=BLOCKED exactly as a human one would. It re-reviews on
push rather than on request, and repeated @coderabbitai triggers over five days
produced nothing, so this is the only lever available without maintainer rights.

The cost is that this dismisses @Vasanthdev2004's and @anandh8x's approvals of
0b6fcf54, which is an unhappy trade for an empty commit — the tree they approved
is byte-identical to this one.

Origin-Session: local-8cd239 | Claude Code | 6 prompts
Origin-Snapshot: 149ab98453ad
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: ae653221fa15

* perf(tools): derive the changed-line span from bytes, not from per-line slices

Reported by @jatmn.

changedLineSpan split both file versions into []string, so it allocated one
string header per line for EACH version — work that scales with the size of the
file rather than the size of the edit. countLines then split a third time just to
take a length. All of it ran before RecordEdit had checked whether there was a
tracked observation to update at all.

He measured 32,036,640 bytes for two 2 MB versions of a million short lines, and
extrapolated roughly 1.6 GB of headers for a 100 MB file before counting the
content, the updated bytes and the hashes already live beside them.

The memory was not the sharp end. edit_file writes the updated bytes BEFORE
calling RecordEdit, so an out-of-memory kill lands after the user's file has
changed and before the tracker baseline catches up: the file and the record of it
disagree, and nothing says so. His instruction was to fix the allocation source
rather than add a size guard, and that a guard — if used at all — would have to
reject before os.WriteFile rather than during post-write re-baselining. Taken as
written: there is no new limit, and the same edits are accepted as before.

Two identical byte prefixes share every line that ends inside them, so counting
newlines in the common prefix counts the unchanged leading lines directly. Same
for the suffix, with one rule that the line-array version got for free: the line
a shared tail STARTS in is only shared when the tail begins on a line boundary in
both versions, or its opening bytes differ and it is a changed line that merely
ends the same way.

Measured on his own scenario, two 2 MB versions:

  changedLineSpan   32,036,640 bytes  ->  0 allocations
  countLines        per-line slice    ->  0 allocations

## The equivalence is tested, not asserted

A rewrite of a diff primitive is only safe if it is behaviour-preserving, so the
replaced implementation is kept in the test file as an oracle and both are asked
the same questions. That caught 206 mismatches in my first attempt at the byte
scan — empty versions, and trailing lines that are only partially shared. Neither
appears in the hand-written cases, and I would have shipped it.

Two regressions: the oracle comparison, and an absolute allocation ceiling rather
than a ratio, so a future rewrite that reintroduces per-line slices fails rather
than merely getting slower. Mutation: restoring strings.Split in either function
allocates 3.2 MB and 3.6 MB against a 4 KB bound.

splitLinesForTracking lost its last caller and is removed — the same obsolete-
helper lint failure that broke Windows CI on #911, caught here by running
make lint-static before pushing rather than after. 0 issues.

Rebased onto ad34dc8d, 0 behind. go test -race ./internal/tools/: clean.
Pre-existing here and on main: TestRunDoctorFormatsRedactedProviderDiagnostics
and TestRunDoctorConnectivityProbesProvider exit 3 in this environment.

Origin-Session: local-c962d7 | Claude Code | 17 prompts
Origin-Snapshot: a599377c09e0
2026-08-26 13:20:48 +08:00
KRATOS 6fe0d1edcd fix(lockutil): use kernel-held advisory locks (#950)
* fix(lockutil): use kernel-held advisory locks

* fix(lockutil): harden cross-platform lock files

* test(lockutil): require one concurrent holder
2026-08-25 21:20:18 +08:00
e59bf78cab fix(sandbox): Make WSL backend tests deterministic. (#948)
Co-authored-by: euxaristia <[email protected]>
Co-authored-by: cairn-code <[email protected]>
2026-08-25 21:19:31 +08:00
Kevin CodexandClaude Fable 5 6645f9744b perf(agent): cheap compaction — dedicated summarizer model, input cap… (#532)
* perf(agent): cheap compaction — dedicated summarizer model, input caps, free reactive prune, window re-derive

Compaction summarization was the single most expensive recurring event
in a long run: the full elided middle — verbatim tool bodies included —
went to the session's main model at full price (a ~100k-token call near
a 200k window, recursively multiplied on overflow).

Four changes, each independently safe:

- Dedicated summarizer model. Compaction summaries route to a cheap
  model resolved as ZERO_COMPACTION_MODEL > preferences.compactionModel
  > a curated default on official endpoints (Haiku 4.5 / Gemini
  Flash-Lite / gpt-4.1-mini; custom-endpoint catalogs are unknowable so
  they get none). Built lazily on the first paid compaction; ANY
  failure permanently falls back to the main provider for the run, so a
  misconfigured summarizer can never break compaction. "main" at any
  level forces the old behavior.

- Summarizer input caps. renderTranscript clamps tool-result bodies to
  2,000 chars and tool-call arguments to 500 (head+tail, rune-safe) —
  the summary needs what was done and decided, not raw payloads. Cuts
  summarizer input 5-10x on tool-heavy sessions.

- Free reactive prune. recover() now runs the same prune-first stage as
  the proactive path: a context-limit error is first answered by
  pruning stale tool bodies at zero cost, without consuming the
  one-shot reactive budget; the paid summarizer only runs when a later
  error finds nothing left to prune.

- Window re-derivation on model switch. Options.ContextWindowFor
  (wired to the model registry in exec and the TUI) lets the loop
  re-derive the compaction threshold after escalate_model, closing the
  documented KNOWN LIMITATION where a switch to a smaller-window model
  could overflow and a larger one over-compacted.

## Rebase notes (2026-08-25, onto 2749447c)

Rebased across 214 commits. main now summarizes through the context planner
(#879) and switches models via turn sessions, so the agent side was reworked
rather than merged textually:

- Summarizer selection lives on compactionState and is resolved lazily per
  main model through summarizeWithPlanner (planner + trace preserved); a
  failing dedicated summarizer is marked broken and the same slice is
  retried once on the main provider.
- Options.Summarizer now receives the main model in force:
  `func(ctx, mainModelID string) (Provider, error)`, and may return
  (nil, nil) when no dedicated model applies. After a successful
  escalate_model switch the loop calls compactor.switchModel, which
  re-derives the window and re-resolves the summarizer — a run that started
  on the cheap model and escalated regains cheap summaries (review P2).
- recover()'s free prune stage runs before the one-shot reactive guard
  (pruning is free and cannot loop) and mirrors maybeCompact's two prune
  passes; the free retry is taken only when the pruned history fits the
  window the provider enforces (with a 5% estimate margin), otherwise the
  same call falls through to the paid summarizer. Gating on the 70%
  proactive threshold would have sent histories in the 70-95% band to the
  summarizer unnecessarily (review P1 and the follow-up question).
- ContextWindowFor is wired through the turn-session switch path; the
  planner's window is updated alongside the compactor's.

Tests: the PR's suite is ported to the new seams, plus insufficient-prune
fall-through, free prune after the budget is spent, window-vs-threshold
gating, and summarizer re-resolution across a model switch.

* fix(agent): branch on a dedicated-summarizer flag instead of comparing providers

Review follow-ups for #532:

- summarizeProvider now reports whether the dedicated summarizer was
  selected and summarizeClosure branches on that flag; the previous
  `chosen == provider` compared Provider interface values, which panics
  when the dynamic type is not comparable. Regression test uses a provider
  with a slice field for both the summarizer and the main provider.
- CompactionModelID's curated-default test covers the official OpenAI
  profile alongside Anthropic and Google.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-25 21:18:04 +08:00
52 changed files with 4108 additions and 1196 deletions

No files matched your search

+172 -11
View File
@@ -4,7 +4,9 @@ import (
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
@@ -396,6 +398,27 @@ type compactionState struct {
// starts at 1.0 and converges via an EMA as each turn reports actual usage, so
// later turns compact nearer to real capacity. Zero is treated as 1.0.
calibrationRatio float64
// summarizerFactory lazily builds the dedicated (cheap) summarizer provider
// (Options.Summarizer) for the current main model. summarizerProvider
// memoizes the built provider and summarizerResolved records that the
// factory ran (it may legitimately return no provider: the main model is
// already the cheap one). summarizerBroken is the sticky fallback: once a
// build or call fails, the run stays on the main provider until the next
// model switch — a misconfigured cheap model must never break (or
// repeatedly delay) compaction.
summarizerFactory func(ctx context.Context, mainModelID string) (Provider, error)
summarizerProvider Provider
summarizerResolved bool
summarizerBroken bool
// window is the context window the threshold was derived from. The
// reactive free-prune stage compares against it (the limit the provider
// enforces), not against the 70% proactive threshold — a pruned history
// between the two would still succeed on retry.
window int
// mainModel is the model the run currently streams on; the summarizer
// choice is made against it and remade after a mid-run switch.
mainModel string
}
// calibrate folds one turn's (rawEstimate, actualPromptTokens) sample into the
@@ -430,17 +453,88 @@ func (state *compactionState) calibratedTokens(raw int) int {
func newCompactionState(options Options, task *taskState) *compactionState {
state := &compactionState{
enabled: options.ContextWindow > 0,
threshold: compactionThreshold(options.ContextWindow),
preserveLast: options.CompactionPreserveLast,
onUsage: options.OnUsage,
task: task,
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
trace: options.Trace,
enabled: options.ContextWindow > 0,
threshold: compactionThreshold(options.ContextWindow),
window: options.ContextWindow,
mainModel: options.Model,
preserveLast: options.CompactionPreserveLast,
onUsage: options.OnUsage,
task: task,
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
trace: options.Trace,
summarizerFactory: options.Summarizer,
}
return state
}
// updateWindow re-derives the compaction threshold after a mid-run model
// switch. The low-water mark is reset: it was measured against the old
// threshold, and keeping it could suppress a compaction the smaller window
// now requires. <= 0 (unknown model) leaves everything unchanged.
func (state *compactionState) updateWindow(window int) {
if window <= 0 {
return
}
state.enabled = true
state.window = window
state.threshold = compactionThreshold(window)
state.lowWaterMark = 0
if state.planner != nil {
state.planner.config.contextWindow = window
}
}
// switchModel records a mid-run model switch: the window is re-derived
// (window <= 0 keeps the old one) and the summarizer choice is forgotten so
// the next compaction re-resolves it against the new main model. A run that
// started on the cheap model (no dedicated summarizer) and escalated to an
// expensive one therefore regains cheap summaries — the exact workflow a
// dedicated summarizer exists for.
func (state *compactionState) switchModel(modelID string, window int) {
state.mainModel = modelID
state.updateWindow(window)
state.summarizerProvider = nil
state.summarizerResolved = false
state.summarizerBroken = false
}
// summarizeProvider returns the provider summarization calls should use and
// whether it is the dedicated summarizer: that one when it applies to the
// current main model and is healthy, else the main provider. The factory runs
// at most once per main model; a build error marks the summarizer broken for
// that model. Callers branch on the boolean rather than comparing Provider
// values — interface comparison panics on a non-comparable dynamic type.
func (state *compactionState) summarizeProvider(ctx context.Context, main Provider) (Provider, bool) {
if state.summarizerFactory == nil || state.summarizerBroken {
return main, false
}
if !state.summarizerResolved {
state.summarizerResolved = true
built, err := state.summarizerFactory(ctx, state.mainModel)
if err != nil {
state.summarizerBroken = true
return main, false
}
state.summarizerProvider = built
}
if state.summarizerProvider == nil {
return main, false
}
return state.summarizerProvider, true
}
// fitsWindow reports whether a calibrated size is safely under the limit the
// provider enforces. The proactive threshold (70% of the window) is a trigger
// for the paid summarizer, not the hard limit; the reactive path compares
// against the window itself, with a small margin for estimate error, so a
// history pruned to, say, 85% of the window takes the free retry.
func (state *compactionState) fitsWindow(size int) bool {
if state.window <= 0 {
return size <= state.threshold
}
return size <= state.window-state.window/20
}
// maybeCompact runs proactive compaction at the top of a turn. It returns the
// (possibly compacted) message slice. It is a no-op when compaction is disabled,
// when the history is under threshold, or when the history has not grown past
@@ -534,10 +628,34 @@ func (state *compactionState) recover(
// context-limit error never triggers an unexpected summarization call.
return messages, false, nil
}
if state.reactiveAttempted {
if !isContextLimitError(errorMessage) {
return messages, false, nil
}
if !isContextLimitError(errorMessage) {
// CHEAP FIRST STAGE (mirrors maybeCompact): a context-limit failure can
// often be fixed for free by dropping superseded reads and pruning old
// tool-result bodies. It runs regardless of the one-shot reactive budget
// (it costs nothing and cannot loop: a second pass finds nothing left to
// prune). The free retry is taken only when the pruned history fits the
// window the provider enforces — otherwise a retry would fail the same
// way, so fall through to the paid summarizer in this same call.
toolTokens := estimateToolDefTokens(tools)
prunedAny := false
if pruned, reclaimed := pruneSupersededReadResults(messages); reclaimed > 0 {
messages, prunedAny = pruned, true
}
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
messages, prunedAny = pruned, true
}
if prunedAny {
size := state.calibratedTokens(estimateTokens(messages) + toolTokens)
if state.fitsWindow(size) {
state.lowWaterMark = size
return messages, true, nil
}
}
if state.reactiveAttempted {
return messages, false, nil
}
@@ -577,11 +695,21 @@ func (state *compactionState) recover(
// provider call. The summary stream intentionally does NOT forward OnText (so
// compaction stays invisible on the user-facing surface), but it DOES forward
// OnUsage so the summarizer's token cost is still counted by usage/budgeting.
// The call goes to the dedicated (cheap) summarizer when one applies; if that
// call fails the summarizer is marked broken for the current model and the
// same slice is retried once on the main provider, so the caller sees at most
// one (main-provider-quality) error.
func (state *compactionState) summarizeClosure(ctx context.Context, provider Provider) func([]zeroruntime.Message) (string, error) {
if state.planner == nil {
state.planner = newContextPlanner(contextPlannerConfig{})
}
return func(toSummarize []zeroruntime.Message) (string, error) {
chosen, dedicated := state.summarizeProvider(ctx, provider)
summary, err := summarizeWithPlanner(ctx, chosen, toSummarize, state.onUsage, state.planner, state.trace)
if err == nil || !dedicated {
return summary, err
}
state.summarizerBroken = true
return summarizeWithPlanner(ctx, provider, toSummarize, state.onUsage, state.planner, state.trace)
}
}
@@ -665,8 +793,41 @@ func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []ze
return summary, nil
}
// summaryToolResultClamp bounds each tool-result body in the summarizer input.
// Tool outputs dominate the elided middle's bulk (build logs, file reads,
// search results), yet the summary instructions only need their gist — the
// model that ACTED on the full output already turned it into decisions the
// surrounding messages record. 2,000 chars (~500 tokens) keeps the useful
// head+tail of a result while cutting the summarizer input by 5-10x on
// tool-heavy sessions; the summarizer's own cost is the price being cut.
const summaryToolResultClamp = 2000
// summaryToolArgsClamp bounds each tool-call argument blob in the transcript:
// a write_file call carries the whole file body in its arguments.
const summaryToolArgsClamp = 500
// clampForSummary head+tail-truncates text for the summarizer transcript,
// snapping to rune boundaries so the clamp never splits a UTF-8 sequence.
func clampForSummary(text string, max int) string {
if max <= 0 || len(text) <= max {
return text
}
head := max * 3 / 4
tail := max - head
for head > 0 && !utf8.RuneStart(text[head]) {
head--
}
start := len(text) - tail
for start < len(text) && !utf8.RuneStart(text[start]) {
start++
}
return text[:head] + "\n…[" + strconv.Itoa(len(text)-head-(len(text)-start)) + " chars omitted for summarization]…\n" + text[start:]
}
// renderTranscript flattens messages into a plain-text transcript for the
// summarizer. Secret scrubbing already happened upstream at the tool boundary.
// Tool-result bodies and tool-call arguments are clamped (see the constants
// above): the summary needs what was done and decided, not the raw payloads.
func renderTranscript(messages []zeroruntime.Message) string {
lines := make([]string, 0, len(messages))
for _, message := range messages {
@@ -676,13 +837,13 @@ func renderTranscript(messages []zeroruntime.Message) string {
if len(message.ToolCalls) > 0 {
calls := make([]string, 0, len(message.ToolCalls))
for _, call := range message.ToolCalls {
calls = append(calls, call.Name+"("+call.Arguments+")")
calls = append(calls, call.Name+"("+clampForSummary(call.Arguments, summaryToolArgsClamp)+")")
}
line += "\n[tool calls: " + strings.Join(calls, "; ") + "]"
}
lines = append(lines, line)
case zeroruntime.MessageRoleTool:
lines = append(lines, "tool result: "+message.Content)
lines = append(lines, "tool result: "+clampForSummary(message.Content, summaryToolResultClamp))
default:
lines = append(lines, string(message.Role)+": "+message.Content)
}
+469
View File
@@ -0,0 +1,469 @@
package agent
import (
"context"
"errors"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// compactableHistory returns a history whose middle is large enough to trip
// the proactive threshold for a 1000-token window.
func compactableHistory() []zeroruntime.Message {
return []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "sys"},
{Role: zeroruntime.MessageRoleUser, Content: strings.Repeat("u", 4000)},
{Role: zeroruntime.MessageRoleAssistant, Content: strings.Repeat("a", 4000)},
{Role: zeroruntime.MessageRoleUser, Content: "u2"},
{Role: zeroruntime.MessageRoleAssistant, Content: "a2"},
{Role: zeroruntime.MessageRoleUser, Content: "u3"},
}
}
// Summarization must run on the dedicated (cheap) summarizer when one is
// configured — the main provider sees no summarize traffic at all.
func TestMaybeCompactUsesDedicatedSummarizer(t *testing.T) {
cheap := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "CHEAP SUMMARY"},
{Type: zeroruntime.StreamEventDone},
}}}
main := &mockProvider{}
factoryCalls := 0
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Summarizer: func(context.Context, string) (Provider, error) {
factoryCalls++
return cheap, nil
},
}, nil)
compacted := state.maybeCompact(context.Background(), main, compactableHistory(), nil)
if len(cheap.requests) == 0 {
t.Fatal("dedicated summarizer never received the summarize call")
}
if len(main.requests) != 0 {
t.Fatalf("main provider must see no summarize traffic, got %d requests", len(main.requests))
}
if factoryCalls != 1 {
t.Fatalf("summarizer factory calls = %d, want 1 (lazy, memoized)", factoryCalls)
}
if estimateTokens(compacted) >= estimateTokens(compactableHistory()) {
t.Fatal("compaction did not shrink the history")
}
found := false
for _, message := range compacted {
if strings.Contains(message.Content, "CHEAP SUMMARY") {
found = true
}
}
if !found {
t.Fatal("cheap summarizer's summary missing from the compacted history")
}
}
// A failing summarizer falls back to the main provider for the SAME slice and
// stays broken for the rest of the run — no repeated failed cheap calls.
func TestSummarizerFailureFallsBackToMainAndSticks(t *testing.T) {
broken := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventError, Error: "boom: model not found"},
}}}
main := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY"}, {Type: zeroruntime.StreamEventDone}},
{{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY 2"}, {Type: zeroruntime.StreamEventDone}},
}}
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Summarizer: func(context.Context, string) (Provider, error) { return broken, nil },
}, nil)
summarize := state.summarizeClosure(context.Background(), main)
input := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}}
summary, err := summarize(input)
if err != nil {
t.Fatalf("fallback must succeed via the main provider: %v", err)
}
if summary != "MAIN SUMMARY" {
t.Fatalf("summary = %q, want the main provider's", summary)
}
if len(broken.requests) != 1 {
t.Fatalf("broken summarizer requests = %d, want exactly 1", len(broken.requests))
}
if !state.summarizerBroken {
t.Fatal("a failed summarizer must be marked broken for the run")
}
// Second summarization: straight to main, no cheap retry.
if _, err := summarize(input); err != nil {
t.Fatal(err)
}
if len(broken.requests) != 1 {
t.Fatalf("broken summarizer was retried (%d requests)", len(broken.requests))
}
if len(main.requests) != 2 {
t.Fatalf("main provider requests = %d, want 2", len(main.requests))
}
}
// A factory that errors (misconfigured model) is equivalent to no summarizer:
// main provider used, factory not retried.
func TestSummarizerFactoryErrorFallsBack(t *testing.T) {
main := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY"},
{Type: zeroruntime.StreamEventDone},
}}}
factoryCalls := 0
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Summarizer: func(context.Context, string) (Provider, error) {
factoryCalls++
return nil, errors.New("unknown model")
},
}, nil)
summarize := state.summarizeClosure(context.Background(), main)
summary, err := summarize([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}})
if err != nil || summary != "MAIN SUMMARY" {
t.Fatalf("summary = %q, err = %v; want main provider fallback", summary, err)
}
if factoryCalls != 1 {
t.Fatalf("factory calls = %d, want 1 (sticky failure)", factoryCalls)
}
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
t.Fatal("after a factory failure the run must stay on the main provider")
}
if factoryCalls != 1 {
t.Fatalf("factory retried after failure (%d calls)", factoryCalls)
}
}
// Tool payloads are clamped in the summarizer transcript: results to
// summaryToolResultClamp, call arguments to summaryToolArgsClamp.
func TestRenderTranscriptClampsToolPayloads(t *testing.T) {
bigResult := strings.Repeat("r", 50_000)
bigArgs := strings.Repeat("w", 20_000)
transcript := renderTranscript([]zeroruntime.Message{
{Role: zeroruntime.MessageRoleAssistant, Content: "editing", ToolCalls: []zeroruntime.ToolCall{
{ID: "c1", Name: "write_file", Arguments: bigArgs},
}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "c1", Content: bigResult},
{Role: zeroruntime.MessageRoleUser, Content: "keep going"},
})
if len(transcript) > summaryToolResultClamp+summaryToolArgsClamp+1024 {
t.Fatalf("transcript is %d bytes; clamps did not apply", len(transcript))
}
if !strings.Contains(transcript, "chars omitted for summarization") {
t.Fatal("clamped transcript must carry the omission marker")
}
if !strings.Contains(transcript, "keep going") {
t.Fatal("non-tool content must survive unclamped")
}
// Head AND tail of the clamped payloads survive.
if !strings.HasPrefix(transcript[strings.Index(transcript, "tool result: ")+len("tool result: "):], "r") ||
!strings.Contains(transcript, "rrr\n\nuser: keep going") {
t.Fatalf("head/tail of the tool result lost:\n%s", transcript[:200])
}
}
// bigToolHistory returns a history with n large tool results (~40k estimated
// tokens each): the trailing protection window keeps the newest verbatim,
// the older ones are prunable.
func bigToolHistory(n int) []zeroruntime.Message {
bigBody := strings.Repeat("x", 160_000)
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "sys"}}
for i := 0; i < n; i++ {
id := string(rune('a' + i))
messages = append(messages,
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: id, Name: "read_file", Arguments: "{\"path\":\"" + id + "\"}"}}},
zeroruntime.Message{Role: zeroruntime.MessageRoleTool, ToolCallID: id, Content: bigBody},
)
}
return append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "go"})
}
func summaryProvider(text string) *mockProvider {
return &mockProvider{turns: [][]zeroruntime.StreamEvent{
{{Type: zeroruntime.StreamEventText, Content: text}, {Type: zeroruntime.StreamEventDone}},
{{Type: zeroruntime.StreamEventText, Content: text}, {Type: zeroruntime.StreamEventDone}},
}}
}
// A context-limit error is first answered with the free prune stage; when the
// pruned history fits the window the retry is free (no summarizer call) and
// the one-shot reactive budget is untouched. The paid summarizer only runs
// when a later error finds nothing left to prune.
func TestRecoverPrunesBeforeSummarizing(t *testing.T) {
provider := summaryProvider("SUMMARY")
// The protected trailing tool output (~40k tokens) must fit the window.
state := newCompactionState(Options{ContextWindow: 200_000, CompactionPreserveLast: 2}, nil)
messages := bigToolHistory(5)
pruned, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried {
t.Fatal("prunable history that fits must produce a free retry")
}
if len(provider.requests) != 0 {
t.Fatalf("free prune stage must not call the summarizer (%d requests)", len(provider.requests))
}
if state.reactiveAttempted {
t.Fatal("a free prune retry must not consume the reactive budget")
}
if estimateTokens(pruned) >= estimateTokens(messages) {
t.Fatal("prune did not shrink the history")
}
// Second context-limit error on the pruned history: nothing meaningfully
// prunable remains, so the paid summarizer fires and consumes the budget.
_, retried, err = state.recover(context.Background(), provider, pruned, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried {
t.Fatal("second recover must compact and retry")
}
if len(provider.requests) == 0 {
t.Fatal("second recover must reach the summarizer")
}
if !state.reactiveAttempted {
t.Fatal("a paid recover must consume the reactive budget")
}
}
// When pruning reclaims something but the pruned history still exceeds the
// window, recover falls through to the paid summarizer in the SAME call
// instead of spending a retry that would fail the same way.
func TestRecoverFallsThroughToSummarizerWhenPruneIsInsufficient(t *testing.T) {
provider := summaryProvider("SUMMARY")
// A 1,000-token window: even the protected trailing output overflows it.
state := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
messages := bigToolHistory(5)
compacted, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried {
t.Fatal("insufficient prune must still end in a retry via summarization")
}
if len(provider.requests) == 0 {
t.Fatal("insufficient prune must reach the summarizer in the same recover call")
}
if !state.reactiveAttempted {
t.Fatal("the paid summarizer consumes the reactive budget")
}
if estimateTokens(compacted) >= estimateTokens(messages) {
t.Fatal("recover did not shrink the history")
}
}
// The free prune stage is not gated behind the one-shot budget: after a paid
// reactive compaction, a later context-limit error still gets a free retry
// when pruning makes the history fit.
func TestRecoverFreePruneRunsAfterBudgetIsSpent(t *testing.T) {
provider := summaryProvider("SUMMARY")
state := newCompactionState(Options{ContextWindow: 200_000, CompactionPreserveLast: 2}, nil)
state.reactiveAttempted = true
messages := bigToolHistory(5)
pruned, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried || len(provider.requests) != 0 {
t.Fatalf("free prune must retry without the summarizer even when the budget is spent (retried=%v, requests=%d)", retried, len(provider.requests))
}
if estimateTokens(pruned) >= estimateTokens(messages) {
t.Fatal("prune did not shrink the history")
}
// With nothing left to prune and the budget spent, the caller keeps its
// original error — recover neither loops nor summarizes.
_, retried, err = state.recover(context.Background(), provider, pruned, nil, "maximum context length exceeded")
if err != nil || retried || len(provider.requests) != 0 {
t.Fatalf("spent budget must not summarize again (retried=%v, requests=%d, err=%v)", retried, len(provider.requests), err)
}
}
// The free-retry gate compares against the window the provider enforces, not
// the 70% proactive threshold: a history pruned to ~80% of the window still
// takes the free retry.
func TestRecoverFreeRetryGateUsesWindowNotThreshold(t *testing.T) {
provider := summaryProvider("SUMMARY")
messages := bigToolHistory(5)
pruned, _ := pruneStaleToolOutput(messages, 2)
prunedTokens := estimateTokens(pruned)
// Choose a window so the pruned size sits between 70% and 95% of it.
window := prunedTokens * 100 / 80
state := newCompactionState(Options{ContextWindow: window, CompactionPreserveLast: 2}, nil)
if prunedTokens <= state.threshold {
t.Fatalf("test setup: pruned size %d must exceed the proactive threshold %d", prunedTokens, state.threshold)
}
_, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried || len(provider.requests) != 0 {
t.Fatalf("a history that fits the window must take the free retry (retried=%v, requests=%d)", retried, len(provider.requests))
}
}
// After a mid-run model switch the summarizer is re-resolved against the new
// main model: a run that started on the cheap model (no dedicated summarizer)
// and escalated gets a cheap summarizer, and a previously broken one is
// retried for the new model.
func TestSwitchModelReresolvesSummarizer(t *testing.T) {
cheap := summaryProvider("CHEAP SUMMARY")
main := summaryProvider("MAIN SUMMARY")
var factoryModels []string
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Model: "cheap-model",
Summarizer: func(_ context.Context, mainModelID string) (Provider, error) {
factoryModels = append(factoryModels, mainModelID)
if mainModelID == "cheap-model" {
return nil, nil // main already is the cheap model
}
return cheap, nil
},
}, nil)
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
t.Fatal("on the cheap model the main provider must summarize")
}
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated || len(factoryModels) != 1 {
t.Fatalf("factory must run once per main model, calls=%v", factoryModels)
}
state.switchModel("big-model", 400_000)
if state.threshold != compactionThreshold(400_000) || state.window != 400_000 {
t.Fatalf("switchModel must re-derive the window, got threshold=%d window=%d", state.threshold, state.window)
}
if got, dedicated := state.summarizeProvider(context.Background(), main); !dedicated || got != Provider(cheap) {
t.Fatal("after escalating to an expensive model the dedicated cheap summarizer must be used")
}
if len(factoryModels) != 2 || factoryModels[1] != "big-model" {
t.Fatalf("factory must be re-invoked with the new main model, calls=%v", factoryModels)
}
// A broken summarizer is retried after the next switch.
state.summarizerBroken = true
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
t.Fatal("a broken summarizer must fall back to main")
}
state.switchModel("other-model", 0)
if got, dedicated := state.summarizeProvider(context.Background(), main); !dedicated || got != Provider(cheap) {
t.Fatal("a model switch must clear the broken flag and rebuild the summarizer")
}
}
func TestCompactionUpdateWindow(t *testing.T) {
state := newCompactionState(Options{ContextWindow: 100_000, CompactionPreserveLast: 2}, nil)
state.lowWaterMark = 50_000
before := state.threshold
state.updateWindow(0) // unknown model: unchanged
if state.threshold != before || state.lowWaterMark != 50_000 {
t.Fatal("updateWindow(0) must be a no-op")
}
state.updateWindow(10_000)
if state.threshold >= before {
t.Fatalf("threshold = %d, want re-derived below %d", state.threshold, before)
}
if state.lowWaterMark != 0 {
t.Fatal("low-water mark must reset on a window change")
}
if !state.enabled {
t.Fatal("a positive window must keep compaction enabled")
}
}
// End-to-end: after escalate_model switches providers, the loop re-derives the
// context window via ContextWindowFor, visible in the OnContext report.
func TestRunEscalationRederivesContextWindow(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "big-model"})
switched := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
}}}
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "escalate"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
}}}
var windows []int
result, err := Run(context.Background(), "go", provider, Options{
Registry: registry,
ContextWindow: 100_000,
Model: "small-model",
ModelSwitcher: func(context.Context, string) (Provider, error) { return switched, nil },
ContextWindowFor: func(modelID string) int {
if modelID == "big-model" {
return 400_000
}
return 0
},
OnContext: func(breakdown ContextBreakdown) { windows = append(windows, breakdown.ContextWindow) },
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
if len(windows) < 2 {
t.Fatalf("expected at least 2 OnContext reports, got %d", len(windows))
}
if windows[0] != 100_000 {
t.Fatalf("turn 1 window = %d, want the original 100000", windows[0])
}
if windows[len(windows)-1] != 400_000 {
t.Fatalf("post-escalation window = %d, want 400000", windows[len(windows)-1])
}
}
// nonComparableProvider has a slice field, so comparing two Provider interface
// values holding it would panic; the summarizer path must never compare them.
type nonComparableProvider struct {
inner *mockProvider
tags []string
}
func (p nonComparableProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
return p.inner.StreamCompletion(ctx, request)
}
func TestSummarizeClosureNeverComparesProviderValues(t *testing.T) {
broken := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventError, Error: "boom: model not found"},
}}}
main := nonComparableProvider{inner: summaryProvider("MAIN SUMMARY"), tags: []string{"x"}}
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Summarizer: func(context.Context, string) (Provider, error) { return nonComparableProvider{inner: broken}, nil },
}, nil)
summary, err := state.summarizeClosure(context.Background(), main)([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}})
if err != nil || summary != "MAIN SUMMARY" {
t.Fatalf("fallback with non-comparable providers must not panic and must use main, got %q err=%v", summary, err)
}
// And with no dedicated summarizer at all the closure still works.
state = newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
if _, err := state.summarizeClosure(context.Background(), main)([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}}); err != nil {
t.Fatal(err)
}
}
+15 -5
View File
@@ -846,11 +846,21 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
options.ServiceTier = ""
planner.config.serviceTier = ""
options.Trace.Counter(trace.CounterModelSwitches, 1)
// KNOWN LIMITATION (deferred): the compactor's context-window budget
// is fixed at run start from options.ContextWindow and is NOT updated
// here, so a switch to a model with a different window keeps compacting
// against the original budget. Fixing it needs the switcher to also
// report the new window — out of scope for this change.
// Re-derive the context window for the new model so compaction and
// the OnContext budget report track the model actually in force —
// without this a switch to a smaller-window model can overflow the
// target, and a switch to a larger one over-compacts. An unknown
// model (<= 0) keeps the original window. The compactor also
// re-resolves its dedicated summarizer against the new model.
window := 0
if options.ContextWindowFor != nil {
window = options.ContextWindowFor(turnRequestedModel)
}
if window > 0 {
options.ContextWindow = window
planner.config.contextWindow = window
}
compactor.switchModel(turnRequestedModel, window)
}
}
}
+20 -4
View File
@@ -348,10 +348,26 @@ type Options struct {
// CompactionPreserveLast is how many trailing messages compaction keeps
// verbatim. <= 0 falls back to defaultCompactionPreserveLast.
CompactionPreserveLast int
Registry *tools.Registry
PermissionMode PermissionMode
Autonomy string
Sandbox *sandbox.Engine
// Summarizer, when set, lazily builds the provider used for compaction
// summarization calls — typically a cheap/fast model, since summaries at
// main-model prices are the single most expensive recurring event in a
// long run. It receives the main model currently in force and may return
// (nil, nil) when no dedicated summarizer applies to it (the main model is
// already the cheap one). Built on the first paid compaction and again
// after a mid-run model switch. Any failure (build or call) falls back to
// the run's main provider until the next switch, so a misconfigured
// summarizer can never break compaction. nil keeps today's behavior.
Summarizer func(ctx context.Context, mainModelID string) (Provider, error)
// ContextWindowFor, when set, resolves a model ID to its context window so
// the compactor can re-derive its threshold after a mid-run model switch
// (escalate_model). Without it a switch keeps compacting against the
// original model's window — overflowing a smaller target or over-compacting
// a larger one. Return <= 0 when the model is unknown (window unchanged).
ContextWindowFor func(modelID string) int
Registry *tools.Registry
PermissionMode PermissionMode
Autonomy string
Sandbox *sandbox.Engine
// FileTracker records per-session file read/write versions so the write tools
// can detect a file changed on disk outside Zero since it was last read. nil
// disables the check. Created once per session and threaded into every tool run.
+1
View File
@@ -974,6 +974,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
FavoriteModels: resolved.Preferences.FavoriteModels,
RecentModels: resolved.Preferences.RecentModels,
RecapsEnabled: resolved.Preferences.RecapsEnabled(),
CompactionModel: resolved.Preferences.CompactionModel,
Provider: provider,
NewProvider: deps.newProvider,
NewTurnSessionProvider: func(profile config.ProviderProfile, provider zeroruntime.Provider) zeroruntime.TurnSessionProvider {
+14 -10
View File
@@ -682,16 +682,20 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
ModelSwitcher: modelSwitcher,
TurnSessionProvider: turnSessions,
ModelSessionSwitcher: modelSessionSwitcher,
ReasoningEffort: forwardEffort,
Trace: traceRecorder,
Cwd: workspaceRoot,
Images: images,
Registry: registry,
PermissionMode: permissionMode,
Autonomy: options.autonomy,
SelfCorrect: selfCorrector,
FileDiagnostics: fileDiagnostics,
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
Summarizer: summarizerFactory(resolved, deps.newProvider),
ContextWindowFor: func(modelID string) int {
return modelregistry.AgentContextWindow(modelContextWindow(modelRegistry, modelID))
},
ReasoningEffort: forwardEffort,
Trace: traceRecorder,
Cwd: workspaceRoot,
Images: images,
Registry: registry,
PermissionMode: permissionMode,
Autonomy: options.autonomy,
SelfCorrect: selfCorrector,
FileDiagnostics: fileDiagnostics,
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
// Headless exec: don't accept a no-tool-call turn as "done" while work
// clearly remains (pending plan items / a mid-step continuation cue) —
// nudge to continue, and finalize as INCOMPLETE rather than false success
+17
View File
@@ -0,0 +1,17 @@
package cli
import (
"context"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// summarizerFactory adapts the resolved profile and the authenticated provider
// builder into agent.Options.Summarizer; the selection rules live in
// providers.CompactionSummarizerFactory, shared with the TUI.
func summarizerFactory(resolved config.ResolvedConfig, newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)) func(context.Context, string) (agent.Provider, error) {
return providers.CompactionSummarizerFactory(resolved.Provider, resolved.Preferences.CompactionModel, newProvider)
}
+6
View File
@@ -119,6 +119,12 @@ type PreferencesConfig struct {
// the user turned idle recaps off. A *bool is its own tri-state, so no
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
Recaps *bool `json:"recaps,omitempty"`
// CompactionModel routes compaction summarization calls to this model
// instead of the session's main model (summaries at main-model prices are
// the most expensive recurring event in long runs). Empty = automatic: a
// curated cheap model on official endpoints, the main model elsewhere.
// "main" forces the main model. ZERO_COMPACTION_MODEL overrides.
CompactionModel string `json:"compactionModel,omitempty"`
}
// RecentModelEntry is one provider-qualified model selection recorded in
+9 -66
View File
@@ -5,31 +5,23 @@ import (
"fmt"
"os"
"path/filepath"
"sync/atomic"
"time"
"github.com/Gitlawb/zero/internal/lockutil"
)
// Cross-process lock tuning. The lock is held only for a single metadata
// read-modify-write (milliseconds), never across a job's exec, so the timeout is
// generous and the stale threshold sits far above any real hold.
// read-modify-write (milliseconds), never across a job's exec, so the timeout
// is generous relative to any real hold.
const (
cronLockTimeout = 10 * time.Second
cronLockStaleAfter = 60 * time.Second
cronLockRetryDelay = 20 * time.Millisecond
)
var cronLockSeq atomic.Uint64
// lockJob takes a cross-process exclusive lock for one job by O_EXCL-creating a
// sibling "<id>.lock" file next to the job directory (so Remove's RemoveAll of
// the job dir never deletes a live lock). It serializes the read-modify-write of
// a job's metadata across concurrent schedulers and commands. The lock file is
// removed on release; a stale lock from a crashed holder (older than
// cronLockStaleAfter) is reclaimed. Wall-clock time.Now is used deliberately
// (not the injectable Store.now) so lock timing never depends on a frozen test
// clock and the stale check compares against real file mtimes.
// lockJob takes a cross-process advisory lock for one job on a stable sibling
// "<id>.lock" file next to the job directory. It serializes the read-modify-write
// of a job's metadata across concurrent schedulers and commands. The kernel
// releases the lock when a holder exits; the file is never renamed or removed.
func (s *Store) lockJob(id string) (func(), error) {
if !validID(id) {
return nil, fmt.Errorf("invalid cron job id %q", id)
@@ -38,64 +30,15 @@ func (s *Store) lockJob(id string) (func(), error) {
if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
return nil, err
}
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), time.Now().UnixNano(), cronLockSeq.Add(1))
deadline := time.Now().Add(cronLockTimeout)
for {
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
lock, err := lockutil.TryAcquireFileLock(lockPath)
if err == nil {
// A partial write would leave a lock file without our token, so the
// releaser could never delete it — stranding the lock. Fail closed.
if _, werr := f.WriteString(token); werr != nil {
_ = f.Close()
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("cron: write job lock: %w", werr)
}
if cerr := f.Close(); cerr != nil {
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("cron: close job lock: %w", cerr)
}
var released bool
return func() {
if released {
return
}
released = true
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
_ = lockutil.RemoveLockFile(lockPath)
}
}, nil
return func() { _ = lock.Release() }, nil
}
// On Windows a concurrent holder's os.Remove leaves the lock file in a
// "delete pending" state, so an O_EXCL create races it with
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
// as contention and retry, exactly like ErrExist — otherwise the lock
// spuriously fails under concurrency on Windows.
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
if !errors.Is(err, lockutil.ErrLockHeld) {
return nil, fmt.Errorf("cron: acquire job lock: %w", err)
}
// Reclaim a stale lock left by a crashed holder — atomically (H3). A blind
// Remove lets two racers both "reclaim" and recreate, so both hold the lock;
// reclaimStaleLock renames the file aside (only one rename of a given file
// wins) and restores it if it turns out fresh, so a live lock is never deleted
// out from under its holder.
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > cronLockStaleAfter {
cleared, rerr := lockutil.ReclaimStaleLock(lockPath, token, func(reclaimedPath string) bool {
info, err := os.Stat(reclaimedPath)
return err == nil && time.Since(info.ModTime()) <= cronLockStaleAfter
})
if rerr != nil {
// Reclaim hit a hard failure: the rename aside failed outright, or a
// live holder's lock could not be put back (the lock path may be
// missing, so re-acquiring would break mutual exclusion). Fail closed
// instead of spinning to the deadline.
return nil, fmt.Errorf("cron: reclaim stale job lock: %w", rerr)
}
if cleared {
continue // cleared a genuinely stale lock; retry the O_EXCL create now
}
// Lost the reclaim race (or it was actually fresh) — fall through to the
// bounded wait instead of hot-spinning on a reclaim that never wins (L13).
}
if !time.Now().Before(deadline) {
return nil, fmt.Errorf("cron: timed out acquiring job lock for %q", id)
}
+1 -1
View File
@@ -64,7 +64,7 @@ func TestMutateRemovedJobReturnsNotFound(t *testing.T) {
if !errors.Is(err, ErrJobNotFound) {
t.Fatalf("Mutate on removed job = %v, want ErrJobNotFound", err)
}
// And the lock file left no litter / did not resurrect the job dir.
// The stable advisory-lock file does not resurrect the removed job dir.
if _, gerr := store.Get(job.ID); !errors.Is(gerr, ErrJobNotFound) {
t.Fatalf("job should remain removed, Get = %v", gerr)
}
+35 -67
View File
@@ -3,107 +3,60 @@ package daemon
import (
"errors"
"fmt"
"io/fs"
"os"
"strconv"
"strings"
"sync/atomic"
"github.com/Gitlawb/zero/internal/lockutil"
)
// Single-instance lock. Mirrors reference-daemon-code-agent-js/supervisor.js's
// lock file: a PID file created with O_EXCL. A second start fails; a STALE lock
// left by a dead daemon (the recorded PID is no longer alive) is reclaimed so the
// daemon recovers from an unclean shutdown without manual cleanup.
// Single-instance lock. The stable PID file carries a kernel advisory lock for
// the daemon's full lifetime. Process exit releases the lock automatically, so
// stale metadata needs no rename/remove recovery protocol.
// ErrAlreadyRunning is returned when a live daemon already holds the lock.
var ErrAlreadyRunning = errors.New("daemon: another instance is already running")
// fileLock is an acquired single-instance lock.
type fileLock struct {
path string
lock *lockutil.FileLock
}
// processAlive reports whether pid is a live process. Implemented per-platform
// (lock_posix.go / lock_windows.go). It is a package var so tests can stub it.
var processAlive = osProcessAlive
// acquireLock takes the single-instance lock at path, reclaiming a stale lock
// whose recorded PID is dead. isAlive overrides the liveness check (tests pass a
// stub); nil uses the real processAlive.
// acquireLock takes the single-instance advisory lock at path. isAlive is used
// only to enrich a contention error with the current holder's PID; the kernel
// lock, not PID metadata, is authoritative.
func acquireLock(path string, isAlive func(pid int) bool) (*fileLock, error) {
if isAlive == nil {
isAlive = processAlive
}
// At most two passes: create, or detect-stale-then-retry once.
for attempt := 0; attempt < 2; attempt++ {
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
// A failed PID write would leave a malformed lock file that another
// process reads as stale (unparsable PID) and wrongly reclaims, breaking
// the single-instance guarantee — so on write failure, remove it and fail.
if _, werr := fmt.Fprintf(f, "%d\n", os.Getpid()); werr != nil {
_ = f.Close()
_ = lockutil.RemoveLockFile(path)
return nil, werr
}
if cerr := f.Close(); cerr != nil {
_ = lockutil.RemoveLockFile(path)
return nil, cerr
}
return &fileLock{path: path}, nil
}
if !errors.Is(err, fs.ErrExist) && !errors.Is(err, os.ErrPermission) {
lock, err := lockutil.TryAcquireFileLock(path)
if err != nil {
if !errors.Is(err, lockutil.ErrLockHeld) {
return nil, err
}
pid, perr := readPidFile(path)
if perr == nil && pid > 0 && isAlive(pid) {
return nil, fmt.Errorf("%w (pid %d)", ErrAlreadyRunning, pid)
}
// Stale lock (dead PID or unreadable) — reclaim it atomically, then retry the
// O_EXCL create. A blind Remove here races: two daemons starting at once could
// both read the stale PID, both Remove, and then one Removes the OTHER's
// freshly-created lock — leaving both "holding" the single-instance lock.
// reclaimStaleLock renames the file aside so only one racer wins the rename,
// and restores it if a live holder reacquired in the gap (D6).
if _, rerr := reclaimStaleLock(path, isAlive); rerr != nil {
// Reclaim hit a hard failure: the rename aside failed outright, or a
// live holder's lock could not be put back (the lock path may be
// missing, so re-acquiring would break the single-instance guarantee).
// Fail closed instead of spinning to the deadline.
return nil, fmt.Errorf("daemon: reclaim stale lock: %w", rerr)
}
return nil, ErrAlreadyRunning
}
return nil, ErrAlreadyRunning
if err := lock.WriteMetadata([]byte(fmt.Sprintf("%d\n", os.Getpid()))); err != nil {
return nil, errors.Join(err, lock.Release())
}
return &fileLock{lock: lock}, nil
}
// daemonLockSeq makes each reclaim attempt's sidelined filename unique per process.
var daemonLockSeq atomic.Uint64
// reclaimStaleLock atomically reclaims a single-instance lock whose recorded
// PID is dead, via lockutil.ReclaimStaleLock: the lock file is renamed aside
// (only one racer can win the rename) and stolen only if the moved file's PID
// is still dead; a holder that reacquired the lock in the gap between the
// stale check and the rename carries a LIVE pid and is restored rather than
// stolen. Returns true only when a genuinely stale lock was removed. A
// non-nil error means the caller must fail closed instead of re-acquiring
// (see lockutil.ReclaimStaleLock).
func reclaimStaleLock(path string, isAlive func(pid int) bool) (bool, error) {
suffix := fmt.Sprintf("%d-%d", os.Getpid(), daemonLockSeq.Add(1))
return lockutil.ReclaimStaleLock(path, suffix, func(reclaimedPath string) bool {
pid, err := readPidFile(reclaimedPath)
return err == nil && pid > 0 && isAlive(pid)
})
}
// release removes the lock file. Safe to call once. An already-missing lock
// file is not an error (lockutil.RemoveLockFile swallows it on every platform).
// release drops the kernel lock and closes its handle. The stable PID file is
// deliberately retained so contenders always address the same inode.
func (l *fileLock) release() error {
if l == nil || l.path == "" {
if l == nil || l.lock == nil {
return nil
}
return lockutil.RemoveLockFile(l.path)
return l.lock.Release()
}
// readPidFile reads and parses the PID recorded in a lock file.
@@ -112,5 +65,20 @@ func readPidFile(path string) (int, error) {
if err != nil {
return 0, err
}
return strconv.Atoi(strings.TrimSpace(string(data)))
metadata := strings.TrimSpace(string(data))
if pid, err := strconv.Atoi(metadata); err == nil {
return pid, nil
}
pidText, sequenceText, ok := strings.Cut(metadata, "-")
if !ok || pidText == "" || sequenceText == "" || strings.Contains(sequenceText, "-") {
return 0, fmt.Errorf("daemon: parse lock PID metadata %q", metadata)
}
if _, err := strconv.ParseUint(sequenceText, 10, 64); err != nil {
return 0, fmt.Errorf("daemon: parse lock holder sequence: %w", err)
}
pid, err := strconv.Atoi(pidText)
if err != nil {
return 0, fmt.Errorf("daemon: parse lock PID: %w", err)
}
return pid, nil
}
+54 -42
View File
@@ -32,27 +32,48 @@ func TestLockSingleInstance(t *testing.T) {
_ = l2.release()
}
func TestLockStaleRecovery(t *testing.T) {
// A PID liveness probe is diagnostic only. Even if it reports the recorded PID
// dead, a contended kernel lock proves that the holder is active and must win.
// The old rename-aside protocol trusted this stale observation and admitted a
// second daemon while the first one was still inside its critical section.
func TestLockDoesNotOverrideKernelHolderWithStalePIDProbe(t *testing.T) {
path := filepath.Join(t.TempDir(), "daemon.lock")
// Simulate a stale lock from a crashed daemon: a PID file whose process is
// dead.
first, err := acquireLock(path, func(int) bool { return true })
if err != nil {
t.Fatalf("first acquireLock: %v", err)
}
defer first.release()
second, err := acquireLock(path, func(int) bool { return false })
if second != nil {
_ = second.release()
}
if !errors.Is(err, ErrAlreadyRunning) {
t.Fatalf("contended acquire with stale PID probe = %v, want ErrAlreadyRunning", err)
}
}
func TestLockIgnoresStaleMetadataWhenKernelLockIsFree(t *testing.T) {
path := filepath.Join(t.TempDir(), "daemon.lock")
// A crashed daemon may leave PID metadata behind, but its kernel lock is
// released automatically, so the next daemon can acquire without moving it.
if err := os.WriteFile(path, []byte("4242\n"), 0o600); err != nil {
t.Fatalf("write stale lock: %v", err)
}
dead := func(int) bool { return false }
l, err := acquireLock(path, dead)
if err != nil {
t.Fatalf("stale-lock recovery failed: %v", err)
t.Fatalf("acquire over stale metadata: %v", err)
}
// The lock now records OUR pid, not the stale one.
data, _ := os.ReadFile(path)
if strings.TrimSpace(string(data)) != strconv.Itoa(os.Getpid()) {
t.Fatalf("reclaimed lock pid = %q, want %d", strings.TrimSpace(string(data)), os.Getpid())
t.Fatalf("lock pid = %q, want %d", strings.TrimSpace(string(data)), os.Getpid())
}
_ = l.release()
}
func TestLockReleaseRemovesFile(t *testing.T) {
func TestLockReleaseKeepsStableFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "daemon.lock")
l, err := acquireLock(path, func(int) bool { return true })
if err != nil {
@@ -61,46 +82,37 @@ func TestLockReleaseRemovesFile(t *testing.T) {
if err := l.release(); err != nil {
t.Fatalf("release: %v", err)
}
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("lock file still present after release: %v", err)
if _, err := os.Stat(path); err != nil {
t.Fatalf("release removed stable lock file: %v", err)
}
}
func TestReclaimStaleLockRemovesDeadHolder(t *testing.T) {
func TestReadPidFileMetadataFormats(t *testing.T) {
path := filepath.Join(t.TempDir(), "daemon.lock")
if err := os.WriteFile(path, []byte("4242\n"), 0o600); err != nil {
t.Fatalf("write stale lock: %v", err)
}
if ok, err := reclaimStaleLock(path, func(int) bool { return false }); err != nil || !ok {
t.Fatalf("reclaimStaleLock must report a genuinely stale (dead-PID) lock reclaimed (ok=%v err=%v)", ok, err)
}
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("a reclaimed stale lock must be removed, stat = %v", err)
}
}
func TestReclaimStaleLockRestoresLiveHolder(t *testing.T) {
// If a holder reacquires the lock in the gap between the stale check and the
// rename, the moved file carries a LIVE pid; reclaim must restore it untouched
// rather than steal it — otherwise two daemons both "hold" the lock (D6).
path := filepath.Join(t.TempDir(), "daemon.lock")
if err := os.WriteFile(path, []byte("4242\n"), 0o600); err != nil {
t.Fatalf("write lock: %v", err)
}
if ok, err := reclaimStaleLock(path, func(int) bool { return true }); err != nil || ok {
t.Fatalf("reclaimStaleLock must NOT report a live-PID lock reclaimed (ok=%v err=%v)", ok, err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("live lock must be restored in place, read = %v", err)
}
if strings.TrimSpace(string(data)) != "4242" {
t.Fatalf("restored lock content = %q, want %q (unchanged)", strings.TrimSpace(string(data)), "4242")
}
// No sidelined ".stale" leftovers.
matches, _ := filepath.Glob(path + ".stale.*")
if len(matches) != 0 {
t.Fatalf("reclaim left sidelined files: %v", matches)
for _, test := range []struct {
name string
metadata string
wantPID int
wantErr bool
}{
{name: "legacy PID", metadata: "4242\n", wantPID: 4242},
{name: "PID and holder sequence", metadata: "4242-7\n", wantPID: 4242},
{name: "missing sequence", metadata: "4242-", wantErr: true},
{name: "non-numeric sequence", metadata: "4242-next", wantErr: true},
{name: "extra component", metadata: "4242-7-8", wantErr: true},
} {
t.Run(test.name, func(t *testing.T) {
if err := os.WriteFile(path, []byte(test.metadata), 0o600); err != nil {
t.Fatal(err)
}
got, err := readPidFile(path)
if (err != nil) != test.wantErr {
t.Fatalf("readPidFile() error = %v, wantErr %v", err, test.wantErr)
}
if !test.wantErr && got != test.wantPID {
t.Fatalf("readPidFile() = %d, want %d", got, test.wantPID)
}
})
}
}
+6 -2
View File
@@ -124,12 +124,16 @@ func TestServerEndToEnd(t *testing.T) {
case <-time.After(3 * time.Second):
t.Fatal("Serve did not return after shutdown")
}
// Socket, status, and lock files are removed on exit.
for _, p := range []string{paths.Socket, paths.Status, paths.Lock} {
// Runtime endpoints are removed on exit. The advisory lock file remains as
// a stable inode, but the kernel lock on it has been released.
for _, p := range []string{paths.Socket, paths.Status} {
if _, err := os.Stat(p); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("file %s not cleaned up after shutdown: %v", p, err)
}
}
if _, err := os.Stat(paths.Lock); err != nil {
t.Fatalf("stable lock file missing after shutdown: %v", err)
}
}
func TestServerSecondInstanceFails(t *testing.T) {
+157 -29
View File
@@ -3,13 +3,93 @@
package execution
import (
"errors"
"os"
"os/exec"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
// requireProcessState refuses to let a broken ps pipeline masquerade as passing
// coverage. Every test in this file proves something about zombie detection,
// and every one of those proofs is read out of ps — so if ps cannot report
// state, the tests verify nothing whatever they do next.
//
// The old code answered that with t.Skip, which is indistinguishable from
// success in CI output. This probes BOTH ps pipelines against this test
// process, which is by definition alive and not a zombie, so a working ps must
// report a live state for it. Failing that:
//
// - In CI (or with ZERO_REQUIRE_PS=1) it is a hard failure. ps is present on
// every image used today, so its absence means the pipeline regressed and
// that has to surface immediately rather than as skipped-green.
// - Elsewhere it stays a skip, so a contributor on an exotic box is not
// blocked by an environment the project does not claim to support.
//
// This only covers "ps cannot report state at all". Once it passes, a later
// failure to observe an EXPECTED state is a real defect, and the tests below
// treat it as one.
func requireProcessState(t *testing.T) {
t.Helper()
self := os.Getpid()
var reasons []string
// The production path: ps -A -o pid=,pgid=,stat=, parsed by
// processTableStateMatches. This is what signalTargetRunning consults.
if running, ok := processIsRunning(self); !ok || !running {
reasons = append(reasons, "the process table read used by signalTargetRunning reported no live state for this test process")
}
// This file's independent oracle: ps -o stat= -p <pid>. It is deliberately
// NOT the production parser, so a regression in that parser cannot hide by
// breaking the test's own precondition check in the same way.
if zombie, ok := processIsZombie(self); !ok || zombie {
reasons = append(reasons, "the per-PID state read used by this test file reported no live state for this test process")
}
if len(reasons) == 0 {
return
}
reason := "cannot read process state, so ps-based zombie detection is unverifiable here: " + strings.Join(reasons, "; ")
if requireWorkingPS() {
t.Fatal(reason + " — failing rather than skipping because CI (or ZERO_REQUIRE_PS) is set, where ps is expected to work")
}
t.Skip(reason)
}
// requireWorkingPS reports whether an unusable ps must fail the run instead of
// skipping it. ZERO_REQUIRE_PS lets a developer opt into the strict behavior
// locally to reproduce what CI does.
func requireWorkingPS() bool {
if strings.TrimSpace(os.Getenv("ZERO_REQUIRE_PS")) != "" {
return true
}
return strings.TrimSpace(os.Getenv("CI")) != ""
}
// awaitZombie blocks until pid is an unreaped zombie. The caller must not have
// waited on the child, so the zombie is a stable state rather than a race: it
// persists until something reaps it. Failing to reach it therefore means the
// child never exited or ps stopped reporting its state, and both are defects
// rather than reasons to skip.
func awaitZombie(t *testing.T, pid int) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for {
zombie, ok := processIsZombie(pid)
if ok && zombie {
return
}
if time.Now().After(deadline) {
if !ok {
t.Fatalf("pid %d left the process table without being reaped; this test never waited on it, so its zombie row must persist", pid)
}
t.Fatalf("pid %d did not reach the zombie state within 5s", pid)
}
time.Sleep(10 * time.Millisecond)
}
}
// TestTerminateProcessTreeTreatsZombieGroupAsExited is the regression test for
// counting unreaped processes as alive: kill(2) with signal 0 succeeds for a
// zombie, so a group whose only remaining member is waiting to be reaped used to
@@ -17,6 +97,8 @@ import (
// as "did not exit after SIGKILL" — a spurious cleanup failure whose timing
// depended on when the reaper ran.
func TestTerminateProcessTreeTreatsZombieGroupAsExited(t *testing.T) {
requireProcessState(t)
cmd := exec.Command("sh", "-c", "exit 0")
ConfigureProcessGroup(cmd)
if err := cmd.Start(); err != nil {
@@ -27,23 +109,59 @@ func TestTerminateProcessTreeTreatsZombieGroupAsExited(t *testing.T) {
// is exactly the state a terminated tree passes through.
t.Cleanup(func() { _ = cmd.Wait() })
deadline := time.Now().Add(5 * time.Second)
for {
if zombie, ok := processIsZombie(pid); ok && zombie {
break
awaitZombie(t, pid)
// Whether a zombie-only group can be signalled is a KERNEL difference, not a
// ps one. On Linux the zombie still occupies the process table and
// kill(-pid, 0) succeeds, so signalTargetRunning goes on to consult ps —
// which is the branch this test was written for. Darwin returns EPERM for
// the same call, so signalTargetRunning short-circuits on the kill and never
// reaches the ps check.
//
// The OUTCOME is identical and is asserted on every platform below. Only the
// branch that produces it differs, so record that instead of skipping the
// whole test (which would drop Darwin's coverage of the outcome) or failing
// it (which would report a supported platform as broken). Any errno other
// than EPERM means the group is not in the state this test needs.
psPathReachable := true
if err := syscall.Kill(-pid, syscall.Signal(0)); err != nil {
if !errors.Is(err, syscall.EPERM) {
t.Fatalf("zombie group %d is unsignalable with an unexpected error (%v); the zombie case is not present, so nothing below would be meaningful", pid, err)
}
if time.Now().After(deadline) {
t.Skip("child did not reach the zombie state; ps output is unavailable in this environment")
}
time.Sleep(10 * time.Millisecond)
}
if syscall.Kill(-pid, syscall.Signal(0)) != nil {
t.Skip("the zombie group is no longer signalable; cannot exercise the case")
psPathReachable = false
t.Logf("kernel refuses to signal zombie-only group %d (EPERM); signalTargetRunning answers from kill(2) rather than ps here, so the ps-based zombie check is asserted directly on platforms that allow the signal", pid)
}
// Where the signal IS permitted, pin the ps-based check itself. This is the
// regression #774 was about, and asserting only signalTargetRunning would
// let a ps-side regression hide behind the kill result.
if psPathReachable {
running, ok := processGroupHasRunningMember(pid)
if !ok {
t.Fatalf("process group %d has no rows while its zombie is unreaped; ps stopped reporting the group rather than the group being gone", pid)
}
if running {
t.Fatal("the ps-based group check must not report a zombie-only group as running")
}
}
// Valid on every platform: whichever branch answers, a zombie-only group is
// not running. On a kernel that refuses the signal this is the whole of the
// coverage, and it is more than the previous skip provided.
if signalTargetRunning(-pid) {
t.Fatal("a group holding only a zombie must not count as running")
}
if !psPathReachable {
// terminateTarget's own kill(2) would hit the same EPERM and be returned
// as a cleanup error, so this assertion cannot be made where the kernel
// refuses the signal. That may be a real gap in TerminateProcessTree on
// such a platform rather than a test limitation — a zombie-only tree is
// already exited and should terminate cleanly — but confirming and
// changing that is a production fix, not this test's business.
t.Log("skipping the TerminateProcessTree assertion: its kill(2) would hit the same EPERM as the probe above")
return
}
if err := TerminateProcessTree(pid, 50*time.Millisecond, 10*time.Millisecond); err != nil {
t.Fatalf("TerminateProcessTree on an already-exited tree: %v", err)
}
@@ -53,6 +171,8 @@ func TestTerminateProcessTreeTreatsZombieGroupAsExited(t *testing.T) {
// a live member must still be seen as running and then actually stopped, so the
// zombie tolerance above cannot degrade into ignoring real processes.
func TestTerminateProcessTreeStopsRunningGroup(t *testing.T) {
requireProcessState(t)
cmd := exec.Command("sh", "-c", "sleep 30")
ConfigureProcessGroup(cmd)
if err := cmd.Start(); err != nil {
@@ -67,7 +187,15 @@ func TestTerminateProcessTreeStopsRunningGroup(t *testing.T) {
if err := TerminateProcessTree(pid, time.Second, 10*time.Millisecond); err != nil {
t.Fatalf("TerminateProcessTree: %v", err)
}
if running, ok := processGroupHasRunningMember(pid); ok && running {
// ok reports whether ANY row for this group was found. This test never waits
// on the child, so its zombie row must still be there — an unknown state
// means ps stopped answering, not that the group is gone. Treating that as a
// pass is what let this assertion succeed without checking anything.
running, ok := processGroupHasRunningMember(pid)
if !ok {
t.Fatalf("process group %d has no rows after termination; the unreaped child's zombie row must persist, so the group's state is undeterminable rather than clean", pid)
}
if running {
t.Fatal("the group still has a running member after termination")
}
}
@@ -82,6 +210,8 @@ func TestTerminateProcessTreeStopsRunningGroup(t *testing.T) {
// "unknown" resulted every time, and signalTargetRunning conservatively (and
// incorrectly) treated a zombie individual-PID target as still running.
func TestSignalTargetRunningTreatsZombieIndividualPIDAsExited(t *testing.T) {
requireProcessState(t)
cmd := exec.Command("sh", "-c", "exit 0")
// Deliberately do NOT call ConfigureProcessGroup: the child inherits this
// test process's group, so it is not its own leader and processSignalTarget
@@ -92,40 +222,38 @@ func TestSignalTargetRunningTreatsZombieIndividualPIDAsExited(t *testing.T) {
pid := cmd.Process.Pid
t.Cleanup(func() { _ = cmd.Wait() })
deadline := time.Now().Add(5 * time.Second)
for {
if zombie, ok := processIsZombie(pid); ok && zombie {
break
}
if time.Now().After(deadline) {
t.Skip("child did not reach the zombie state; ps output is unavailable in this environment")
}
time.Sleep(10 * time.Millisecond)
}
awaitZombie(t, pid)
target, err := processSignalTarget(pid)
if err != nil {
t.Fatalf("processSignalTarget: %v", err)
}
// The child inherited this process's group, so its PGID is this process's
// PID and can never equal its own PID. A negative target here would mean
// processSignalTarget misread the group, which is the very thing the
// individual-PID fallback exists to handle.
if target != pid {
t.Skip("child unexpectedly became its own group leader; cannot exercise the individual-PID fallback")
t.Fatalf("processSignalTarget(%d) = %d, want the individual PID: the child inherited this process's group, so it is not its own leader", pid, target)
}
if signalTargetRunning(target) {
t.Fatal("a zombie individual-PID target must not count as running")
}
}
// processIsZombie reports a process's zombie state via ps. ok is false when the
// state could not be read.
// processIsZombie reports a process's zombie state via ps. It is deliberately a
// separate, per-PID ps invocation rather than a reuse of the production
// process-table parser: these tests assert what that parser concludes, so
// borrowing it to establish their own preconditions would let one regression
// silence both sides at once.
//
// ok is false when no state could be read, which after requireProcessState has
// passed means the process left the table rather than that ps is broken.
func processIsZombie(pid int) (zombie bool, ok bool) {
output, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output()
if err != nil {
return false, false
}
state := string(output)
for len(state) > 0 && (state[0] == ' ' || state[0] == '\n' || state[0] == '\t') {
state = state[1:]
}
state := strings.TrimLeft(string(output), " \n\t")
if state == "" {
return false, false
}
+9 -63
View File
@@ -5,93 +5,39 @@ import (
"fmt"
"os"
"path/filepath"
"sync/atomic"
"time"
"github.com/Gitlawb/zero/internal/lockutil"
)
// Cross-process lock tuning for the audit log. The lock is held only across a
// single read-then-append (milliseconds), so the timeout is generous and the
// stale threshold sits far above any real hold.
// single read-then-append (milliseconds), so the timeout is generous relative
// to any real hold.
const (
auditLockTimeout = 10 * time.Second
auditLockStaleAfter = 60 * time.Second
auditLockRetryDelay = 20 * time.Millisecond
)
var auditLockSeq atomic.Uint64
// lockAudit takes a cross-process exclusive lock on the audit log by
// O_EXCL-creating a sibling "<auditPath>.lock" file (removed on release). It makes
// lockAudit takes a cross-process advisory lock on the audit log through a
// stable sibling "<auditPath>.lock" file. It makes
// the lastSequence()+append in append() atomic across processes; the audit log is
// a shared global file, so without it two processes can read the same last
// sequence and write duplicate numbers. A stale lock from a crashed holder (older
// than auditLockStaleAfter) is reclaimed. Wall-clock time is used deliberately so
// lock timing never depends on the store's injectable clock and the stale check
// compares against real file mtimes. This mirrors internal/cron/lock.go and
// internal/oauth/lock.go. Uses internal/lockutil for lock restore and reclaim.
// sequence and write duplicate numbers. Kernel lock lifetime provides crash
// recovery without moving or deleting the lock path.
func (store *AuditStore) lockAudit() (func(), error) {
lockPath := store.auditPath + ".lock"
if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
return nil, err
}
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), time.Now().UnixNano(), auditLockSeq.Add(1))
deadline := time.Now().Add(auditLockTimeout)
for {
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
lock, err := lockutil.TryAcquireFileLock(lockPath)
if err == nil {
// A partial write would leave a lock file without our token, so the
// releaser could never delete it — stranding the lock. Fail closed.
if _, werr := f.WriteString(token); werr != nil {
_ = f.Close()
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("hooks: write audit lock: %w", werr)
}
if cerr := f.Close(); cerr != nil {
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("hooks: close audit lock: %w", cerr)
}
var released bool
return func() {
if released {
return
}
released = true
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
_ = lockutil.RemoveLockFile(lockPath)
}
}, nil
return func() { _ = lock.Release() }, nil
}
// On Windows a concurrent holder's os.Remove leaves the lock file in a
// "delete pending" state, so an O_EXCL create races it with
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat both
// as contention and retry.
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
if !errors.Is(err, lockutil.ErrLockHeld) {
return nil, fmt.Errorf("hooks: acquire audit lock: %w", err)
}
// Reclaim a stale lock left by a crashed holder — atomically (H3). A blind
// Remove lets two racers both reclaim + recreate and so both hold the lock;
// reclaimStaleLock renames the file aside (only one rename wins) and restores
// it if it turns out fresh, so a live lock is never deleted out from under it.
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > auditLockStaleAfter {
cleared, rerr := lockutil.ReclaimStaleLock(lockPath, token, func(reclaimedPath string) bool {
info, err := os.Stat(reclaimedPath)
return err == nil && time.Since(info.ModTime()) <= auditLockStaleAfter
})
if rerr != nil {
// Reclaim hit a hard failure: the rename aside failed outright, or a
// live holder's lock could not be put back (the lock path may be
// missing, so re-acquiring would break mutual exclusion). Fail closed
// instead of spinning to the deadline.
return nil, fmt.Errorf("hooks: reclaim stale audit lock: %w", rerr)
}
if cleared {
continue
}
// Lost the reclaim race (or it was actually fresh) — fall through to the
// bounded wait rather than hot-spinning on a reclaim that never wins.
}
if !time.Now().Before(deadline) {
return nil, fmt.Errorf("hooks: timed out acquiring audit lock")
}
+111 -48
View File
@@ -1,64 +1,127 @@
// Package lockutil provides the platform-specific file primitives behind the
// O_EXCL lock files in cron, daemon, hooks, oauth, and swarm: a no-overwrite
// restore for locks that were sidelined during a stale-reclaim attempt, and a
// lock file remover with one cross-platform contract (missing files are a
// no-op; Windows retries transient sharing violations).
// Package lockutil provides cross-process advisory file locks used by cron,
// daemon, hooks, oauth, and swarm. Locks are held by the kernel for the lifetime
// of an open file handle, so process exit releases them without a stale-file
// reclaim protocol. The lock file itself is stable: holders never rename or
// remove it while another process may still have its inode locked.
package lockutil
import (
"io"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
)
// restoreByCopy restores reclaimed to path without overwriting an existing
// path, as a fallback for when the platform's primary no-replace primitive
// (hard link on POSIX, MoveFileEx on Windows) fails for a reason other than
// the destination existing. It stages a full copy under a private name next
// to reclaimed and publishes it to path with publish (the same no-replace
// primitive the caller's platform uses for the primary restore), so path
// never appears in a partially-copied state: a crash between staging and
// publish leaves path exactly as it was (missing or, if a competing holder
// created it, untouched), never a truncated file that a PID/content-based
// liveness check could mistake for dead. Leaving path missing here would let
// the next O_EXCL create succeed while the sidelined holder is still live,
// breaking mutual exclusion. publish keeps the no-overwrite guarantee: a new
// holder that appeared in the meantime wins and this returns os.ErrExist. The
// copy resets the lock's mtime to now, which only makes the restored lock
// look fresher; that is safe, since it is being handed back to a live holder.
func restoreByCopy(reclaimed, path string, publish func(from, to string) error) error {
staged := reclaimed + ".copy"
src, err := os.Open(reclaimed)
var (
// ErrLockHeld reports that another process or goroutine currently holds the
// advisory lock. Callers may wait and retry until their own deadline.
ErrLockHeld = errors.New("lockutil: lock is held")
// ErrLockReleased reports an attempt to update metadata after Release.
ErrLockReleased = errors.New("lockutil: lock is released")
)
var holderSequence atomic.Uint64
// FileLock is an exclusive advisory lock held through an open file handle.
// Release is idempotent. The lock path remains on disk after release; deleting
// or replacing it would let different processes lock different inodes.
type FileLock struct {
mu sync.Mutex
file *os.File
state platformLockState
released bool
releaseErr error
}
// TryAcquireFileLock attempts to take an exclusive advisory lock without
// waiting. A contended lock returns ErrLockHeld. The kernel releases a lock if
// its process exits, so an old unlocked file never needs stale reclamation.
func TryAcquireFileLock(path string) (*FileLock, error) {
return TryAcquireFileLockAt(filepath.Dir(path), path)
}
// TryAcquireFileLockAt attempts to lock path while confining every component
// below root to a handle-relative traversal. root is the caller's trusted
// boundary and may itself be reached through a legitimate link; links and
// reparse points below it are rejected before metadata can be written.
func TryAcquireFileLockAt(root, path string) (*FileLock, error) {
if root == "" {
return nil, errors.New("lockutil: lock root is empty")
}
relative, err := filepath.Rel(root, path)
if err != nil {
return err
return nil, fmt.Errorf("lockutil: resolve lock path relative to root: %w", err)
}
dst, err := os.OpenFile(staged, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
relative = filepath.Clean(relative)
if relative == "." || relative == ".." || filepath.IsAbs(relative) || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return nil, fmt.Errorf("lockutil: lock path %q escapes root %q", path, root)
}
file, err := openLockFileAt(root, relative, path)
if err != nil {
_ = src.Close()
return err
return nil, fmt.Errorf("lockutil: open lock file: %w", err)
}
_, err = io.Copy(dst, src)
// Close the source before removing anything below: Go opens files without
// FILE_SHARE_DELETE on Windows, so deleting reclaimed or staged while src
// is open would fail with a sharing violation.
_ = src.Close()
state, contended, err := tryLockFile(file)
if err != nil {
_ = dst.Close()
_ = os.Remove(staged)
return err
_ = file.Close()
return nil, fmt.Errorf("lockutil: acquire advisory lock: %w", err)
}
if err := dst.Close(); err != nil {
_ = os.Remove(staged)
return err
if contended {
_ = file.Close()
return nil, ErrLockHeld
}
if err := publish(staged, path); err != nil {
_ = os.Remove(staged)
return err
lock := &FileLock{file: file, state: state}
metadata := []byte(fmt.Sprintf("%d-%d\n", os.Getpid(), holderSequence.Add(1)))
if err := lock.WriteMetadata(metadata); err != nil {
return nil, errors.Join(err, lock.Release())
}
return lock, nil
}
// WriteMetadata replaces the diagnostic contents of the lock file while the
// caller holds it. Metadata does not establish ownership; the kernel lock does.
func (lock *FileLock) WriteMetadata(data []byte) error {
if lock == nil {
return ErrLockReleased
}
lock.mu.Lock()
defer lock.mu.Unlock()
if lock.released || lock.file == nil {
return ErrLockReleased
}
if err := lock.file.Truncate(0); err != nil {
return fmt.Errorf("lockutil: truncate lock metadata: %w", err)
}
if _, err := lock.file.Seek(0, 0); err != nil {
return fmt.Errorf("lockutil: seek lock metadata: %w", err)
}
if _, err := lock.file.Write(data); err != nil {
return fmt.Errorf("lockutil: write lock metadata: %w", err)
}
// publish may leave staged behind (a hard-link publish keeps both names),
// so remove it explicitly; a move-based publish already made this a no-op.
_ = os.Remove(staged)
// The lock is back at path, so the restore has succeeded; failing to clean
// up the sidelined name must not be reported as a failed restore.
_ = RemoveLockFile(reclaimed)
return nil
}
// Release unlocks and closes the held file handle. It deliberately leaves the
// stable lock file in place. Repeated calls return the first release result.
func (lock *FileLock) Release() error {
if lock == nil {
return nil
}
lock.mu.Lock()
defer lock.mu.Unlock()
if lock.released {
return lock.releaseErr
}
lock.released = true
unlockErr := unlockFile(lock.file, &lock.state)
closeErr := lock.file.Close()
lock.file = nil
if err := errors.Join(unlockErr, closeErr); err != nil {
lock.releaseErr = fmt.Errorf("lockutil: release advisory lock: %w", err)
}
return lock.releaseErr
}
+82 -33
View File
@@ -4,43 +4,92 @@ package lockutil
import (
"errors"
"io/fs"
"fmt"
"os"
"path/filepath"
"strings"
"golang.org/x/sys/unix"
)
// RestoreLockFile restores a sidelined lock file on non-Windows platforms. It
// uses os.Link so a competing lock created at path in the meantime wins: the
// link fails with os.ErrExist instead of overwriting it. If the link fails for
// any other reason (hardlink-incapable filesystems such as FAT or some
// FUSE/network mounts, ENOSPC, EPERM), it falls back to an O_EXCL copy rather
// than leaving path missing and the live holder's lock stranded in reclaimed.
// Once the lock is back at path the restore has succeeded, so a failed cleanup
// of the sidelined name is not reported as an error; the leftover file is
// invisible to the lock protocol.
func RestoreLockFile(reclaimed, path string) error {
err := os.Link(reclaimed, path)
type platformLockState struct{}
func openLockFileAt(root, relative, displayPath string) (_ *os.File, resultErr error) {
parentFD, err := unix.Open(root, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
if err != nil {
return nil, fmt.Errorf("open lock root: %w", err)
}
defer func() {
if parentFD >= 0 {
resultErr = errors.Join(resultErr, unix.Close(parentFD))
}
}()
components := strings.Split(filepath.Clean(relative), string(filepath.Separator))
for _, component := range components[:len(components)-1] {
nextFD, openErr := unix.Openat(parentFD, component, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
if openErr != nil {
return nil, fmt.Errorf("open lock path component %q: %w", component, openErr)
}
if closeErr := unix.Close(parentFD); closeErr != nil {
_ = unix.Close(nextFD)
parentFD = -1
return nil, fmt.Errorf("close lock path component: %w", closeErr)
}
parentFD = nextFD
}
name := components[len(components)-1]
fd := -1
// Darwin can transiently return ENOENT when many callers race to create the
// same O_NOFOLLOW entry. The bound parent handle cannot be redirected, so a
// bounded retry preserves first-use contention without weakening no-follow.
for attempt := 0; attempt < 10; attempt++ {
fd, err = unix.Openat(parentFD, name, unix.O_CREAT|unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0o600)
if !errors.Is(err, unix.ENOENT) {
break
}
}
if err != nil {
return nil, err
}
file := os.NewFile(uintptr(fd), displayPath)
if file == nil {
_ = unix.Close(fd)
return nil, errors.New("convert lock handle to file")
}
var stat unix.Stat_t
if err := unix.Fstat(fd, &stat); err != nil {
return nil, errors.Join(fmt.Errorf("inspect lock file: %w", err), file.Close())
}
if stat.Mode&unix.S_IFMT != unix.S_IFREG {
return nil, errors.Join(errors.New("refusing non-regular lock file"), file.Close())
}
if stat.Nlink != 1 {
return nil, errors.Join(errors.New("refusing multiply-linked lock file"), file.Close())
}
if err := unix.Close(parentFD); err != nil {
parentFD = -1
return nil, errors.Join(fmt.Errorf("close lock parent: %w", err), file.Close())
}
parentFD = -1
return file, nil
}
func tryLockFile(file *os.File) (platformLockState, bool, error) {
err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB)
if err == nil {
_ = RemoveLockFile(reclaimed)
return platformLockState{}, false, nil
}
if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
return platformLockState{}, true, nil
}
return platformLockState{}, false, err
}
func unlockFile(file *os.File, _ *platformLockState) error {
if file == nil {
return nil
}
if errors.Is(err, os.ErrExist) {
return err
}
return restoreByCopy(reclaimed, path, os.Link)
}
// isReclaimContended reports whether a failed rename-aside of a suspected
// stale lock means it was lost to a racer rather than a hard failure. POSIX
// rename has no contention errno (a lost race surfaces only as ENOENT, which
// ReclaimStaleLock already treats as benign), so nothing extra is benign here.
func isReclaimContended(error) bool { return false }
// RemoveLockFile removes a lock file on non-Windows platforms. Removing an
// already-missing file reports nil, matching the Windows implementation, so
// callers see one cross-platform contract.
func RemoveLockFile(path string) error {
if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
return nil
return unix.Flock(int(file.Fd()), unix.LOCK_UN)
}
+205 -101
View File
@@ -1,116 +1,220 @@
package lockutil
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
)
func TestRestoreLockFile(t *testing.T) {
tempDir := t.TempDir()
path := filepath.Join(tempDir, "lock")
reclaimed := filepath.Join(tempDir, "lock.stale.token")
// 1. Successful restore when target does not exist
if err := os.WriteFile(reclaimed, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
if err := RestoreLockFile(reclaimed, path); err != nil {
t.Fatalf("RestoreLockFile failed: %v", err)
}
if data, err := os.ReadFile(path); err != nil || string(data) != "token" {
t.Fatalf("restored lock content = %q, err %v; want %q intact", data, err, "token")
}
if _, err := os.Stat(reclaimed); !os.IsNotExist(err) {
t.Fatalf("expected sidelined lock to be cleaned up/removed: %v", err)
}
// 2. Fail when target already exists (prevent overwrite)
if err := os.WriteFile(reclaimed, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("competing"), 0o600); err != nil {
t.Fatal(err)
}
err := RestoreLockFile(reclaimed, path)
if err == nil {
t.Fatal("expected RestoreLockFile to fail when target exists")
}
if !errors.Is(err, os.ErrExist) {
t.Fatalf("expected os.ErrExist, got: %v", err)
}
// The sidelined lock must still exist on failure, and the competing lock
// must be untouched.
if _, err := os.Stat(reclaimed); err != nil {
t.Fatalf("expected sidelined lock to still exist: %v", err)
}
if data, _ := os.ReadFile(path); string(data) != "competing" {
t.Fatalf("competing lock was clobbered: %q", data)
}
}
func TestRestoreByCopy(t *testing.T) {
tempDir := t.TempDir()
path := filepath.Join(tempDir, "lock")
reclaimed := filepath.Join(tempDir, "lock.stale.token")
// 1. Restores content and cleans up the sidelined file when the target is
// free (the fallback taken when the platform's no-replace primitive fails
// for a reason other than the destination existing).
if err := os.WriteFile(reclaimed, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
if err := restoreByCopy(reclaimed, path, os.Link); err != nil {
t.Fatalf("restoreByCopy failed: %v", err)
}
if data, err := os.ReadFile(path); err != nil || string(data) != "token" {
t.Fatalf("restored lock content = %q, err %v; want %q intact", data, err, "token")
}
if _, err := os.Stat(reclaimed); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected sidelined lock to be cleaned up after copy: %v", err)
}
if _, err := os.Stat(reclaimed + ".copy"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected staged copy to be cleaned up: %v", err)
}
// 2. Keeps the no-overwrite guarantee: a competing lock at the target wins
// and the sidelined lock survives for the caller's ErrExist cleanup.
if err := os.WriteFile(reclaimed, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("competing"), 0o600); err != nil {
t.Fatal(err)
}
if err := restoreByCopy(reclaimed, path, os.Link); !errors.Is(err, os.ErrExist) {
t.Fatalf("expected os.ErrExist when target exists, got: %v", err)
}
if data, _ := os.ReadFile(path); string(data) != "competing" {
t.Fatalf("competing lock was clobbered: %q", data)
}
if _, err := os.Stat(reclaimed); err != nil {
t.Fatalf("expected sidelined lock to still exist: %v", err)
}
if _, err := os.Stat(reclaimed + ".copy"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected staged copy to be cleaned up after a failed publish: %v", err)
}
}
func TestRemoveLockFile(t *testing.T) {
func TestFileLockSerializesAndKeepsStablePath(t *testing.T) {
path := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(path, []byte("token"), 0o600); err != nil {
t.Fatal(err)
first, err := TryAcquireFileLock(path)
if err != nil {
t.Fatalf("first acquire: %v", err)
}
if err := RemoveLockFile(path); err != nil {
t.Fatalf("RemoveLockFile failed: %v", err)
firstInfo, err := os.Stat(path)
if err != nil {
t.Fatalf("stat first lock path: %v", err)
}
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected lock file to be removed: %v", err)
if _, err := TryAcquireFileLock(path); !errors.Is(err, ErrLockHeld) {
t.Fatalf("contended acquire = %v, want ErrLockHeld", err)
}
// Removing an already-missing lock file is a no-op on every platform.
if err := RemoveLockFile(path); err != nil {
t.Fatalf("RemoveLockFile on a missing file = %v, want nil", err)
if err := first.Release(); err != nil {
t.Fatalf("release first: %v", err)
}
releasedInfo, err := os.Stat(path)
if err != nil {
t.Fatalf("release removed stable lock path: %v", err)
}
if !os.SameFile(firstInfo, releasedInfo) {
t.Fatal("release replaced the stable lock file")
}
second, err := TryAcquireFileLock(path)
if err != nil {
t.Fatalf("acquire after release: %v", err)
}
secondInfo, err := os.Stat(path)
if err != nil {
t.Fatalf("stat second lock path: %v", err)
}
if !os.SameFile(firstInfo, secondInfo) {
t.Fatal("reacquire used a different lock file")
}
if err := second.Release(); err != nil {
t.Fatalf("release second: %v", err)
}
if err := second.Release(); err != nil {
t.Fatalf("idempotent release: %v", err)
}
}
func TestFileLockMetadata(t *testing.T) {
path := filepath.Join(t.TempDir(), "lock")
lock, err := TryAcquireFileLock(path)
if err != nil {
t.Fatal(err)
}
if err := lock.WriteMetadata([]byte("holder\n")); err != nil {
t.Fatalf("write metadata: %v", err)
}
data, err := os.ReadFile(path)
if err != nil || string(data) != "holder\n" {
t.Fatalf("metadata = %q, err %v", data, err)
}
if err := lock.Release(); err != nil {
t.Fatal(err)
}
if err := lock.WriteMetadata(nil); !errors.Is(err, ErrLockReleased) {
t.Fatalf("write after release = %v, want ErrLockReleased", err)
}
}
func TestFileLockConcurrentFirstAcquisition(t *testing.T) {
path := filepath.Join(t.TempDir(), "lock")
const contenders = 200
start := make(chan struct{})
results := make(chan error, contenders)
locks := make(chan *FileLock, contenders)
var ready sync.WaitGroup
ready.Add(contenders)
for range contenders {
go func() {
ready.Done()
<-start
lock, err := TryAcquireFileLock(path)
if lock != nil {
locks <- lock
}
results <- err
}()
}
ready.Wait()
close(start)
acquired := 0
for range contenders {
err := <-results
switch {
case err == nil:
acquired++
case !errors.Is(err, ErrLockHeld):
t.Errorf("concurrent first acquire: %v", err)
}
}
close(locks)
for lock := range locks {
if err := lock.Release(); err != nil {
t.Error(err)
}
}
if acquired != 1 {
t.Fatalf("concurrent first acquire: got %d holders, want 1", acquired)
}
}
func TestFileLockRefusesMultiplyLinkedEntry(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "target")
lockPath := filepath.Join(dir, "lock")
const sentinel = "do not overwrite"
if err := os.WriteFile(target, []byte(sentinel), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Link(target, lockPath); err != nil {
t.Fatalf("create redirected hard link: %v", err)
}
if lock, err := TryAcquireFileLock(lockPath); err == nil {
_ = lock.Release()
t.Fatal("TryAcquireFileLock accepted a multiply-linked entry")
}
data, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if string(data) != sentinel {
t.Fatalf("redirected target was modified: %q", data)
}
}
func TestFileLockAtRejectsEscapingPath(t *testing.T) {
base := t.TempDir()
outside := filepath.Join(filepath.Dir(base), "outside.lock")
if lock, err := TryAcquireFileLockAt(base, outside); err == nil {
_ = lock.Release()
t.Fatal("TryAcquireFileLockAt accepted a path outside its root")
}
if _, err := os.Stat(outside); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("escaping path was created: %v", err)
}
}
// TestFileLockReleasedByProcessExit proves crash recovery is provided by the
// kernel lock lifetime. The child exits without calling Release; the next
// process can then lock the same persistent file without moving or deleting it.
func TestFileLockReleasedByProcessExit(t *testing.T) {
path := filepath.Join(t.TempDir(), "lock")
cmd := exec.Command(os.Args[0], "-test.run=^TestFileLockHelperProcess$")
cmd.Env = append(os.Environ(), "ZERO_LOCKUTIL_HELPER=1", "ZERO_LOCKUTIL_PATH="+path)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
stdin, err := cmd.StdinPipe()
if err != nil {
t.Fatal(err)
}
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
ready, err := bufio.NewReader(stdout).ReadString('\n')
if err != nil || strings.TrimSpace(ready) != "ready" {
_ = cmd.Process.Kill()
_ = cmd.Wait()
t.Fatalf("helper readiness = %q, err %v", ready, err)
}
if _, err := TryAcquireFileLock(path); !errors.Is(err, ErrLockHeld) {
_ = cmd.Process.Kill()
_ = cmd.Wait()
t.Fatalf("acquire while helper holds lock = %v, want ErrLockHeld", err)
}
if err := stdin.Close(); err != nil {
t.Fatal(err)
}
if err := cmd.Wait(); err != nil {
t.Fatalf("helper exit: %v", err)
}
lock, err := TryAcquireFileLock(path)
if err != nil {
t.Fatalf("acquire after helper exit: %v", err)
}
if err := lock.Release(); err != nil {
t.Fatal(err)
}
}
func TestFileLockHelperProcess(t *testing.T) {
if os.Getenv("ZERO_LOCKUTIL_HELPER") != "1" {
return
}
lock, err := TryAcquireFileLock(os.Getenv("ZERO_LOCKUTIL_PATH"))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if _, err := fmt.Fprintln(os.Stdout, "ready"); err != nil {
os.Exit(3)
}
_, _ = io.Copy(io.Discard, os.Stdin)
runtime.KeepAlive(lock) // deliberately exit without Release
}
+183 -58
View File
@@ -4,77 +4,202 @@ package lockutil
import (
"errors"
"fmt"
"os"
"syscall"
"time"
"path/filepath"
"strings"
"unsafe"
"golang.org/x/sys/windows"
)
// RestoreLockFile restores a sidelined lock file on Windows. It moves the file
// with a no-replace MoveFileEx so a competing lock created at path in the
// meantime wins: the move fails with os.ErrExist instead of overwriting it. If
// the move fails for any other reason (ERROR_SHARING_VIOLATION or
// ERROR_ACCESS_DENIED under concurrent access to the file), it falls back to
// an O_EXCL copy rather than leaving path missing and the live holder's lock
// stranded in reclaimed.
func RestoreLockFile(reclaimed, path string) error {
err := moveFileNoReplace(reclaimed, path)
if err == nil || errors.Is(err, os.ErrExist) {
return err
}
return restoreByCopy(reclaimed, path, moveFileNoReplace)
type platformLockState struct {
overlapped windows.Overlapped
}
// moveFileNoReplace renames from to to, failing if to already exists. It calls
// MoveFileExW directly via golang.org/x/sys/windows (the standard library's
// syscall package does not export MoveFileEx on this platform) with no flags,
// so an existing destination fails with ERROR_ALREADY_EXISTS, which satisfies
// errors.Is(err, os.ErrExist) (pinned by
// TestMoveFileNoReplaceMapsAlreadyExistsToErrExist), instead of overwriting it
// the way os.Rename does on Windows (it passes MOVEFILE_REPLACE_EXISTING to
// match POSIX rename semantics cross-platform).
func moveFileNoReplace(from, to string) error {
fromPtr, err := windows.UTF16PtrFromString(from)
const lockByteOffsetHigh = 1
func openLockFileAt(root, relative, displayPath string) (_ *os.File, resultErr error) {
rootPtr, err := windows.UTF16PtrFromString(root)
if err != nil {
return err
return nil, err
}
toPtr, err := windows.UTF16PtrFromString(to)
parent, err := windows.CreateFile(
rootPtr,
windows.FILE_READ_ATTRIBUTES|windows.FILE_TRAVERSE|windows.SYNCHRONIZE,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil,
windows.OPEN_EXISTING,
windows.FILE_FLAG_BACKUP_SEMANTICS,
0,
)
if err != nil {
return err
return nil, fmt.Errorf("open lock root: %w", err)
}
return windows.MoveFileEx(fromPtr, toPtr, 0)
}
// isReclaimContended reports whether a failed rename-aside of a suspected
// stale lock means the file was concurrently open or pending deletion (which
// surfaces as ERROR_SHARING_VIOLATION or ERROR_ACCESS_DENIED on Windows)
// rather than a hard failure, so ReclaimStaleLock reports a lost race instead
// of an error. Neither errno satisfies errors.Is(err, os.ErrExist), and
// ERROR_SHARING_VIOLATION does not satisfy errors.Is(err, os.ErrPermission)
// either, so the classification compares raw errnos like RemoveLockFile below.
func isReclaimContended(err error) bool {
var errno syscall.Errno
return errors.As(err, &errno) && (errno == windows.ERROR_SHARING_VIOLATION || errno == windows.ERROR_ACCESS_DENIED)
}
// RemoveLockFile removes a lock file on Windows, retrying on the sharing
// violation or access denied errors that are common under heavy concurrent
// contention. Removing an already-missing file reports nil, matching the
// non-Windows implementation, so callers see one cross-platform contract.
func RemoveLockFile(path string) error {
var err error
for i := 0; i < 15; i++ {
err = os.Remove(path)
if err == nil || os.IsNotExist(err) {
return nil
defer func() {
if parent != 0 {
resultErr = errors.Join(resultErr, windows.CloseHandle(parent))
}
var errno syscall.Errno
if errors.As(err, &errno) && (errno == windows.ERROR_SHARING_VIOLATION || errno == windows.ERROR_ACCESS_DENIED) {
time.Sleep(5 * time.Millisecond)
}()
components := strings.Split(filepath.Clean(relative), string(filepath.Separator))
for _, component := range components[:len(components)-1] {
next, openErr := openLockDirectoryAt(parent, component)
if openErr != nil {
return nil, fmt.Errorf("open lock path component %q: %w", component, openErr)
}
if closeErr := windows.CloseHandle(parent); closeErr != nil {
_ = windows.CloseHandle(next)
parent = 0
return nil, fmt.Errorf("close lock path component: %w", closeErr)
}
parent = next
}
handle, err := openLockFileHandleAt(parent, components[len(components)-1])
if err != nil {
return nil, err
}
file := os.NewFile(uintptr(handle), displayPath)
if file == nil {
_ = windows.CloseHandle(handle)
return nil, errors.New("convert lock handle to file")
}
if err := windows.CloseHandle(parent); err != nil {
parent = 0
return nil, errors.Join(fmt.Errorf("close lock parent: %w", err), file.Close())
}
parent = 0
return file, nil
}
func openLockDirectoryAt(parent windows.Handle, name string) (windows.Handle, error) {
handle, err := openLockPathAt(
parent,
name,
windows.FILE_READ_ATTRIBUTES|windows.FILE_TRAVERSE|windows.SYNCHRONIZE,
windows.FILE_OPEN,
windows.FILE_ATTRIBUTE_DIRECTORY,
windows.FILE_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
)
if err != nil {
return 0, err
}
if err := validateWindowsLockHandle(handle, true); err != nil {
return 0, errors.Join(err, windows.CloseHandle(handle))
}
return handle, nil
}
func openLockFileHandleAt(parent windows.Handle, name string) (windows.Handle, error) {
for attempt := 0; attempt < 10; attempt++ {
handle, err := openLockPathAt(
parent,
name,
windows.GENERIC_READ|windows.GENERIC_WRITE|windows.SYNCHRONIZE,
windows.FILE_OPEN,
windows.FILE_ATTRIBUTE_NORMAL,
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
)
if err == nil {
if err := validateWindowsLockHandle(handle, false); err != nil {
return 0, errors.Join(err, windows.CloseHandle(handle))
}
return handle, nil
}
if !windowsLockPathMissing(err) {
return 0, err
}
handle, err = openLockPathAt(
parent,
name,
windows.GENERIC_READ|windows.GENERIC_WRITE|windows.SYNCHRONIZE,
windows.FILE_CREATE,
windows.FILE_ATTRIBUTE_NORMAL,
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
)
if err == windows.STATUS_OBJECT_NAME_COLLISION || errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
continue
}
return err
if err != nil {
return 0, err
}
if err := validateWindowsLockHandle(handle, false); err != nil {
return 0, errors.Join(err, windows.CloseHandle(handle))
}
return handle, nil
}
return err
return 0, errors.New("lock file creation did not stabilize")
}
func windowsLockPathMissing(err error) bool {
return err == windows.STATUS_NO_SUCH_FILE ||
err == windows.STATUS_OBJECT_NAME_NOT_FOUND ||
err == windows.STATUS_OBJECT_PATH_NOT_FOUND ||
errors.Is(err, windows.ERROR_FILE_NOT_FOUND) ||
errors.Is(err, windows.ERROR_PATH_NOT_FOUND)
}
func openLockPathAt(parent windows.Handle, name string, access, disposition, attributes, options uint32) (windows.Handle, error) {
objectName, err := windows.NewNTUnicodeString(name)
if err != nil {
return 0, err
}
objectAttributes := &windows.OBJECT_ATTRIBUTES{
Length: uint32(unsafe.Sizeof(windows.OBJECT_ATTRIBUTES{})),
RootDirectory: parent,
ObjectName: objectName,
Attributes: windows.OBJ_CASE_INSENSITIVE | windows.OBJ_DONT_REPARSE,
}
var handle windows.Handle
err = windows.NtCreateFile(
&handle,
access,
objectAttributes,
&windows.IO_STATUS_BLOCK{},
nil,
attributes,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
disposition,
options,
0,
0,
)
return handle, err
}
func validateWindowsLockHandle(handle windows.Handle, wantDirectory bool) error {
var info windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
return fmt.Errorf("inspect lock path handle: %w", err)
}
isDirectory := info.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 || isDirectory != wantDirectory {
return errors.New("refusing non-file or reparse-point lock path")
}
if !wantDirectory && info.NumberOfLinks != 1 {
return errors.New("refusing multiply-linked lock file")
}
return nil
}
func tryLockFile(file *os.File) (platformLockState, bool, error) {
state := platformLockState{overlapped: windows.Overlapped{OffsetHigh: lockByteOffsetHigh}}
flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY)
err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, &state.overlapped)
if err == nil {
return state, false, nil
}
if errors.Is(err, windows.ERROR_LOCK_VIOLATION) || errors.Is(err, windows.ERROR_SHARING_VIOLATION) {
return platformLockState{}, true, nil
}
return platformLockState{}, false, err
}
func unlockFile(file *os.File, state *platformLockState) error {
if file == nil {
return nil
}
return windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, &state.overlapped)
}
@@ -1,83 +0,0 @@
//go:build windows
package lockutil
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
"golang.org/x/sys/windows"
)
// TestMoveFileNoReplaceMapsAlreadyExistsToErrExist pins the errno coupling
// every caller keys its cleanup on: with no flags, MoveFileEx fails with
// ERROR_ALREADY_EXISTS when the destination exists, and that errno must
// satisfy errors.Is(err, os.ErrExist).
func TestMoveFileNoReplaceMapsAlreadyExistsToErrExist(t *testing.T) {
dir := t.TempDir()
from := filepath.Join(dir, "from")
to := filepath.Join(dir, "to")
if err := os.WriteFile(from, []byte("sidelined"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(to, []byte("competing"), 0o600); err != nil {
t.Fatal(err)
}
err := moveFileNoReplace(from, to)
if !errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
t.Fatalf("MoveFileEx with an existing destination = %v, want ERROR_ALREADY_EXISTS", err)
}
if !errors.Is(err, os.ErrExist) {
t.Fatalf("ERROR_ALREADY_EXISTS must satisfy errors.Is(err, os.ErrExist), got: %v", err)
}
if data, _ := os.ReadFile(to); string(data) != "competing" {
t.Fatalf("destination was overwritten: %q", data)
}
}
// TestRemoveLockFileRetriesSharingViolation holds the file open briefly (Go
// opens files without FILE_SHARE_DELETE on Windows, so a concurrent DeleteFile
// fails with ERROR_SHARING_VIOLATION while the handle is open); RemoveLockFile
// must ride its retry loop (15 x 5ms) past the window and succeed once the
// handle closes.
func TestRemoveLockFileRetriesSharingViolation(t *testing.T) {
path := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(path, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
go func() {
time.Sleep(10 * time.Millisecond)
_ = f.Close()
}()
if err := RemoveLockFile(path); err != nil {
t.Fatalf("RemoveLockFile should succeed once the contending handle closes: %v", err)
}
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected lock file to be removed: %v", err)
}
}
// TestRemoveLockFileSharingViolationExhausts verifies the retry loop is
// bounded: with the contending handle held for the whole call, RemoveLockFile
// surfaces the sharing violation instead of retrying forever.
func TestRemoveLockFileSharingViolationExhausts(t *testing.T) {
path := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(path, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := RemoveLockFile(path); !errors.Is(err, windows.ERROR_SHARING_VIOLATION) {
t.Fatalf("RemoveLockFile with a held handle = %v, want ERROR_SHARING_VIOLATION", err)
}
}
-82
View File
@@ -1,82 +0,0 @@
package lockutil
import (
"errors"
"os"
)
// restoreLockFile is swappable so tests can force the fail-closed path of
// ReclaimStaleLock, which requires both the fast restore and its no-replace
// fallback (with its own copy fallback) to fail; that cannot be provoked
// portably on a healthy filesystem.
var restoreLockFile = restoreLiveLock
// restoreLiveLock puts a lock that turned out to be live back at path after
// ReclaimStaleLock moved it aside to inspect it. It first tries a fast,
// replacing rename straight from reclaimed to path: a single syscall, which
// keeps the window during which path does not exist (and so is open to a
// fourth process's unrelated O_EXCL create landing on it) as short as
// possible. RestoreLockFile's no-replace restore (with its slower copy
// fallback on some failures) is a correctness-preserving fallback for when
// the fast path itself fails (e.g. a cross-device sidelined name): it is a
// much longer version of the identical race, but still detects rather than
// silently clobbers a competing lock, which the fast path's replacing rename
// cannot do. Neither path makes the race impossible, only unlikely; see
// ReclaimStaleLock's doc comment.
func restoreLiveLock(reclaimed, path string) error {
if err := os.Rename(reclaimed, path); err == nil {
return nil
}
return RestoreLockFile(reclaimed, path)
}
// ReclaimStaleLock atomically reclaims a suspected-stale lock file. It renames
// lockPath aside to "<lockPath>.stale.<suffix>" (only one racer can win the
// rename of a given file, so two racers can never both reclaim the same lock),
// then consults isLive on the moved file; if the lock turns out to be live (a
// holder reacquired it in the gap between the caller's stale check and the
// rename) it is restored rather than stolen. The suffix must be unique per
// acquirer attempt. Returns true only when a genuinely stale lock was removed,
// so the caller knows it is safe to retry its exclusive create immediately; on
// a lost race it returns false. A non-nil error means either the rename aside
// failed for a reason that is not contention (so retrying cannot help and the
// caller should fail fast instead of spinning to its deadline), or a live
// holder's lock could not be restored (both restoreLiveLock's fast path and
// its no-replace fallback failed), so lockPath may be missing; callers must
// fail closed instead of re-acquiring. The sidelined file is removed on every
// restore failure: once the restore has failed it has no protocol function
// (release only consults the lock path), so keeping it would only leak files.
//
// The live-restore path has an inherent, unclosed race: between the rename
// aside above and restoreLiveLock putting the lock back, lockPath does not
// exist, so an unrelated caller's O_EXCL create can legitimately succeed
// there. restoreLiveLock's fast path then silently overwrites that new
// claimant's lock file, which does not corrupt release (it is
// ownership-aware, so the new claimant's later release safely no-ops against
// content it no longer owns) but does mean the new claimant can still run
// its critical section concurrently with the original live holder. Making
// this race actually impossible would need an OS-level advisory lock (flock
// / LockFileEx) held for a holder's whole critical section, checked
// non-destructively instead of by moving the file; restoreLiveLock only
// shrinks the window to roughly one syscall, it does not close it.
func ReclaimStaleLock(lockPath, suffix string, isLive func(reclaimedPath string) bool) (bool, error) {
reclaimed := lockPath + ".stale." + suffix
if err := os.Rename(lockPath, reclaimed); err != nil {
if errors.Is(err, os.ErrNotExist) || isReclaimContended(err) {
return false, nil // another racer already moved/removed it, or it vanished
}
return false, err
}
if isLive(reclaimed) {
// Put the live lock back instead of stealing it, and let the caller wait.
if rerr := restoreLockFile(reclaimed, lockPath); rerr != nil {
_ = RemoveLockFile(reclaimed)
if !errors.Is(rerr, os.ErrExist) {
return false, rerr
}
}
return false, nil
}
_ = RemoveLockFile(reclaimed)
return true, nil
}
-31
View File
@@ -1,31 +0,0 @@
//go:build !windows
package lockutil
import (
"os"
"path/filepath"
"testing"
)
// TestReclaimStaleLockPropagatesRenameFailure pins the fail-fast contract of
// the rename aside: a failure that is not a lost race must reach the caller.
// Mapping it to the benign lost-race result would leave acquirers spinning
// until their deadline and reporting a timeout instead of the real cause. A
// directory squatting on the sidelined name makes os.Rename fail with EISDIR,
// which matches neither os.ErrNotExist nor a contention errno.
func TestReclaimStaleLockPropagatesRenameFailure(t *testing.T) {
lockPath := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(lockPath, []byte("stale-holder"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(lockPath+".stale.tok", 0o755); err != nil {
t.Fatal(err)
}
if ok, err := ReclaimStaleLock(lockPath, "tok", func(string) bool { return false }); err == nil || ok {
t.Fatalf("a hard rename failure must surface an error (ok=%v err=%v)", ok, err)
}
if _, err := os.Stat(lockPath); err != nil {
t.Fatalf("the lock file must be left in place on a failed rename: %v", err)
}
}
-112
View File
@@ -1,112 +0,0 @@
package lockutil
import (
"errors"
"os"
"path/filepath"
"testing"
)
func TestReclaimStaleLock(t *testing.T) {
lockPath := filepath.Join(t.TempDir(), "lock")
dead := func(string) bool { return false }
live := func(string) bool { return true }
// A lock the predicate reports dead is reclaimed and removed.
if err := os.WriteFile(lockPath, []byte("crashed-holder"), 0o600); err != nil {
t.Fatal(err)
}
if ok, err := ReclaimStaleLock(lockPath, "tok-a", dead); err != nil || !ok {
t.Fatalf("a dead lock should be reclaimed (ok=%v err=%v)", ok, err)
}
if _, err := os.Stat(lockPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("reclaimed dead lock should be gone, stat err=%v", err)
}
// A LIVE lock (a holder reacquired in the gap) must be RESTORED intact.
if err := os.WriteFile(lockPath, []byte("live-holder"), 0o600); err != nil {
t.Fatal(err)
}
if ok, err := ReclaimStaleLock(lockPath, "tok-b", live); err != nil || ok {
t.Fatalf("a live lock must not be reclaimed (ok=%v err=%v)", ok, err)
}
if data, err := os.ReadFile(lockPath); err != nil || string(data) != "live-holder" {
t.Fatalf("live lock must be left intact, got %q err %v", data, err)
}
// A missing lock reports no reclaim (nothing to steal).
_ = os.Remove(lockPath)
if ok, err := ReclaimStaleLock(lockPath, "tok-c", live); err != nil || ok {
t.Fatalf("a missing lock should not report a reclaim (ok=%v err=%v)", ok, err)
}
}
// restoreLiveLock's fast path is a single replacing rename, chosen to
// minimize (not eliminate) the window during which lockPath is absent and
// open to an unrelated O_EXCL create; see ReclaimStaleLock's doc comment for
// why that residual race exists. This test pins the resulting behavior: the
// fast path overwrites a competing file at path rather than detecting it,
// unlike RestoreLockFile's own no-replace contract.
func TestRestoreLiveLockFastPathOverwritesCompetingFile(t *testing.T) {
dir := t.TempDir()
reclaimed := filepath.Join(dir, "lock.stale.tok")
path := filepath.Join(dir, "lock")
if err := os.WriteFile(reclaimed, []byte("original-holder"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("new-claimant"), 0o600); err != nil {
t.Fatal(err)
}
if err := restoreLiveLock(reclaimed, path); err != nil {
t.Fatalf("restoreLiveLock failed: %v", err)
}
data, err := os.ReadFile(path)
if err != nil || string(data) != "original-holder" {
t.Fatalf("path = %q, err %v; want the original holder's content restored", data, err)
}
if _, err := os.Stat(reclaimed); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected reclaimed to be consumed by the rename: %v", err)
}
}
func TestReclaimStaleLockFailsClosedOnRestoreError(t *testing.T) {
// When both the no-replace restore and its copy fallback fail (only
// provokable via the seam; a healthy filesystem cannot produce it), the
// caller must receive an error so it fails closed instead of re-acquiring a
// missing lock path, and the sidelined file must not leak.
restoreLockFile = func(reclaimed, path string) error { return errors.New("restore failed") }
defer func() { restoreLockFile = restoreLiveLock }()
lockPath := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(lockPath, []byte("live-holder"), 0o600); err != nil {
t.Fatal(err)
}
ok, err := ReclaimStaleLock(lockPath, "tok", func(string) bool { return true })
if err == nil || ok {
t.Fatalf("a failed restore must surface an error (ok=%v err=%v)", ok, err)
}
if matches, _ := filepath.Glob(lockPath + ".stale.*"); len(matches) != 0 {
t.Fatalf("a failed restore must not leak sidelined files: %v", matches)
}
}
func TestReclaimStaleLockDropsSidelinedWhenNewHolderWins(t *testing.T) {
// An os.ErrExist restore failure means a new holder recreated the lock
// path; that is not an error for the caller, and the sidelined file is
// dropped rather than leaked.
restoreLockFile = func(reclaimed, path string) error { return os.ErrExist }
defer func() { restoreLockFile = restoreLiveLock }()
lockPath := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(lockPath, []byte("live-holder"), 0o600); err != nil {
t.Fatal(err)
}
ok, err := ReclaimStaleLock(lockPath, "tok", func(string) bool { return true })
if err != nil || ok {
t.Fatalf("losing to a new holder is not an error (ok=%v err=%v)", ok, err)
}
if matches, _ := filepath.Glob(lockPath + ".stale.*"); len(matches) != 0 {
t.Fatalf("the sidelined file must be dropped when a new holder wins: %v", matches)
}
}
-50
View File
@@ -1,50 +0,0 @@
//go:build windows
package lockutil
import (
"os"
"path/filepath"
"testing"
)
// TestReclaimStaleLockTreatsSharingViolationAsLostRace pins the errno
// classification the Windows acquire loops depend on: a rename aside that
// fails because the lock file is concurrently open (Go opens files without
// FILE_SHARE_DELETE, so the rename fails with ERROR_SHARING_VIOLATION or
// ERROR_ACCESS_DENIED) is contention, not a hard failure, and must report a
// benign lost race so the caller keeps waiting.
func TestReclaimStaleLockTreatsSharingViolationAsLostRace(t *testing.T) {
lockPath := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(lockPath, []byte("held"), 0o600); err != nil {
t.Fatal(err)
}
f, err := os.Open(lockPath)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if ok, rerr := ReclaimStaleLock(lockPath, "tok", func(string) bool { return true }); rerr != nil || ok {
t.Fatalf("a sharing-violation rename must report a benign lost race (ok=%v err=%v)", ok, rerr)
}
if _, err := os.Stat(lockPath); err != nil {
t.Fatalf("the lock file must be left in place: %v", err)
}
}
// TestReclaimStaleLockPropagatesRenameFailure pins the fail-fast contract of
// the rename aside on Windows: a failure that is not contention must reach
// the caller instead of masquerading as a lost race. A quote is an invalid
// filename character, so the sidelined name fails with ERROR_INVALID_NAME.
func TestReclaimStaleLockPropagatesRenameFailure(t *testing.T) {
lockPath := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(lockPath, []byte("stale-holder"), 0o600); err != nil {
t.Fatal(err)
}
if ok, err := ReclaimStaleLock(lockPath, `bad"name`, func(string) bool { return false }); err == nil || ok {
t.Fatalf("a hard rename failure must surface an error (ok=%v err=%v)", ok, err)
}
if _, err := os.Stat(lockPath); err != nil {
t.Fatalf("the lock file must be left in place on a failed rename: %v", err)
}
}
+7 -64
View File
@@ -5,24 +5,16 @@ import (
"fmt"
"os"
"path/filepath"
"sync/atomic"
"time"
"github.com/Gitlawb/zero/internal/lockutil"
)
const (
fileLockTimeout = 5 * time.Second
fileLockStaleAfter = 30 * time.Second
)
const fileLockTimeout = 5 * time.Second
var lockSeq atomic.Uint64
// acquireFileLock takes a cross-process exclusive lock by creating lockPath with
// O_EXCL. It retries with a short backoff until a timeout, breaking a lock whose
// file is older than fileLockStaleAfter (so a crashed holder cannot deadlock the
// store). Release is ownership-aware: it removes the lock only if it still holds
// our token, so a stale-broken holder cannot delete a newer holder's lock.
// acquireFileLock takes a cross-process advisory lock through a stable lock
// file. It retries with a short backoff until a timeout. Kernel lock lifetime
// provides crash recovery, so the path is never renamed or removed.
func acquireFileLock(lockPath string, now func() time.Time) (func(), error) {
if now == nil {
now = time.Now
@@ -30,64 +22,15 @@ func acquireFileLock(lockPath string, now func() time.Time) (func(), error) {
if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
return nil, err
}
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), now().UnixNano(), lockSeq.Add(1))
deadline := now().Add(fileLockTimeout)
for {
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
lock, err := lockutil.TryAcquireFileLock(lockPath)
if err == nil {
// A partial/failed write would leave a lock file without our token, so
// the releaser below could never delete it — stranding the lock for
// other processes. Fail closed: remove the file and surface the error.
if _, werr := f.WriteString(token); werr != nil {
_ = f.Close()
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("oauth: write token lock: %w", werr)
}
if cerr := f.Close(); cerr != nil {
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("oauth: close token lock: %w", cerr)
}
var released bool
return func() {
if released {
return
}
released = true
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
_ = lockutil.RemoveLockFile(lockPath)
}
}, nil
return func() { _ = lock.Release() }, nil
}
// On Windows a concurrent holder's os.Remove leaves the lock file in a
// "delete pending" state, so an O_EXCL create races it with
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
// as contention and retry, exactly like ErrExist — otherwise the lock
// spuriously fails under concurrency on Windows.
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
if !errors.Is(err, lockutil.ErrLockHeld) {
return nil, fmt.Errorf("oauth: acquire token lock: %w", err)
}
// Reclaim a stale lock left by a crashed holder — atomically (H3). A blind
// Remove lets two racers both reclaim + recreate and so both hold the lock;
// reclaimStaleLock renames the file aside (only one rename wins) and restores
// it if it turns out fresh, so a live lock is never deleted out from under it.
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > fileLockStaleAfter {
cleared, rerr := lockutil.ReclaimStaleLock(lockPath, token, func(reclaimedPath string) bool {
info, err := os.Stat(reclaimedPath)
return err == nil && time.Since(info.ModTime()) <= fileLockStaleAfter
})
if rerr != nil {
// Reclaim hit a hard failure: the rename aside failed outright, or a
// live holder's lock could not be put back (the lock path may be
// missing, so re-acquiring would break mutual exclusion). Fail closed
// instead of spinning to the deadline.
return nil, fmt.Errorf("oauth: reclaim stale token lock: %w", rerr)
}
if cleared {
continue
}
// Lost the reclaim race (or it was actually fresh) — fall through to the
// bounded wait rather than hot-spinning on a reclaim that never wins.
}
if now().After(deadline) {
return nil, fmt.Errorf("oauth: timed out acquiring token lock %s", filepath.Base(lockPath))
}
+108
View File
@@ -0,0 +1,108 @@
package providers
import (
"context"
"os"
"strings"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// Compaction summarization model selection.
//
// Compaction summaries used to run on the session's main model — near a 200k
// window one summarization is a ~100k-token input call at full price, the
// single most expensive recurring event in a long run. A cheap model handles
// the "dense factual summary" task fine, so summarization is routed to one
// whenever the target is knowable.
//
// Resolution order: ZERO_COMPACTION_MODEL env > preferences.compactionModel
// config > a curated per-kind default (OFFICIAL endpoints only) > "" (use the
// main model). The literal value "main" at any level forces the main model.
// A wrong choice is never fatal: the agent falls back to the main provider on
// the first summarizer failure for the rest of the run.
// CompactionModelEnv overrides the compaction summarization model.
const CompactionModelEnv = "ZERO_COMPACTION_MODEL"
// compactionMainSentinel disables the dedicated summarizer explicitly.
const compactionMainSentinel = "main"
// Curated cheap summarizer defaults, applied only for provider kinds whose
// model catalog is guaranteed (official endpoints). Custom / *-compatible
// endpoints get NO default — their catalogs are unknowable, and a guaranteed
// failed call per run is worse than main-model prices.
const (
defaultAnthropicCompactionModel = "claude-haiku-4-5-20251001"
defaultGoogleCompactionModel = "gemini-2.5-flash-lite"
defaultOpenAICompactionModel = "gpt-4.1-mini"
)
// CompactionModelID returns the model that compaction summarization calls
// should use for the given profile, or "" to use the session's main model.
// preference is the resolved config value (preferences.compactionModel).
func CompactionModelID(profile config.ProviderProfile, preference string) string {
value := strings.TrimSpace(os.Getenv(CompactionModelEnv))
if value == "" {
value = strings.TrimSpace(preference)
}
if value != "" {
if strings.EqualFold(value, compactionMainSentinel) {
return ""
}
return value
}
return defaultCompactionModel(profile)
}
func defaultCompactionModel(profile config.ProviderProfile) string {
metadata, err := ResolveRuntimeMetadata(profile, Options{})
if err != nil {
return ""
}
var candidate string
switch metadata.ProviderKind {
case config.ProviderKindAnthropic:
candidate = defaultAnthropicCompactionModel
case config.ProviderKindGoogle:
candidate = defaultGoogleCompactionModel
case config.ProviderKindOpenAI:
candidate = defaultOpenAICompactionModel
default:
return ""
}
// Session already runs on the cheap model: a dedicated summarizer buys
// nothing and would only add a second provider instance.
if strings.EqualFold(strings.TrimSpace(metadata.APIModel), candidate) {
return ""
}
return candidate
}
// CompactionSummarizerFactory returns the lazy constructor the agent loop
// calls for its dedicated compaction summarizer (agent.Options.Summarizer).
// The compactor passes the main model in force at that moment — on the first
// paid compaction and again after a mid-run model switch — so the choice
// tracks escalations: a run that starts on the cheap model gets no dedicated
// summarizer, and gains one once it escalates to a pricier main model.
// Returning (nil, nil) means "summarize on the main provider". The factory
// itself is nil only when no provider builder exists. Shared by the exec and
// TUI front ends so the selection rules cannot drift between them.
func CompactionSummarizerFactory(base config.ProviderProfile, preference string, newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)) func(context.Context, string) (zeroruntime.Provider, error) {
if newProvider == nil {
return nil
}
return func(_ context.Context, mainModelID string) (zeroruntime.Provider, error) {
profile := base
if strings.TrimSpace(mainModelID) != "" {
profile.Model = mainModelID
}
modelID := CompactionModelID(profile, preference)
if modelID == "" {
return nil, nil
}
profile.Model = modelID
return newProvider(profile)
}
}
+113
View File
@@ -0,0 +1,113 @@
package providers
import (
"context"
"errors"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestCompactionModelIDResolutionOrder(t *testing.T) {
anthropic := config.ProviderProfile{
Name: "anthropic",
ProviderKind: config.ProviderKindAnthropic,
Model: "claude-sonnet-4.5",
}
t.Setenv(CompactionModelEnv, "my-env-model")
if got := CompactionModelID(anthropic, "my-config-model"); got != "my-env-model" {
t.Fatalf("env must win, got %q", got)
}
t.Setenv(CompactionModelEnv, "main")
if got := CompactionModelID(anthropic, "my-config-model"); got != "" {
t.Fatalf("env 'main' must force the main model, got %q", got)
}
t.Setenv(CompactionModelEnv, "")
if got := CompactionModelID(anthropic, "my-config-model"); got != "my-config-model" {
t.Fatalf("config preference must apply when env unset, got %q", got)
}
if got := CompactionModelID(anthropic, "main"); got != "" {
t.Fatalf("config 'main' must force the main model, got %q", got)
}
}
func TestCompactionModelIDCuratedDefaults(t *testing.T) {
t.Setenv(CompactionModelEnv, "")
anthropic := config.ProviderProfile{Name: "anthropic", ProviderKind: config.ProviderKindAnthropic, Model: "claude-sonnet-4.5"}
if got := CompactionModelID(anthropic, ""); got != defaultAnthropicCompactionModel {
t.Fatalf("anthropic default = %q, want %q", got, defaultAnthropicCompactionModel)
}
google := config.ProviderProfile{Name: "google", ProviderKind: config.ProviderKindGoogle, Model: "gemini-2.5-pro"}
if got := CompactionModelID(google, ""); got != defaultGoogleCompactionModel {
t.Fatalf("google default = %q, want %q", got, defaultGoogleCompactionModel)
}
openai := config.ProviderProfile{Name: "openai", ProviderKind: config.ProviderKindOpenAI, Model: "gpt-4.1"}
if got := CompactionModelID(openai, ""); got != defaultOpenAICompactionModel {
t.Fatalf("openai default = %q, want %q", got, defaultOpenAICompactionModel)
}
// Custom/compatible endpoints: catalog unknowable, no default.
compatible := config.ProviderProfile{
Name: "opengateway",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://opengateway.gitlawb.com/v1",
Model: "some-model",
}
if got := CompactionModelID(compatible, ""); got != "" {
t.Fatalf("openai-compatible must have no default, got %q", got)
}
}
func TestCompactionModelIDSkipsWhenMainIsAlreadyCheap(t *testing.T) {
t.Setenv(CompactionModelEnv, "")
haiku := config.ProviderProfile{
Name: "anthropic",
ProviderKind: config.ProviderKindAnthropic,
Model: defaultAnthropicCompactionModel,
}
if got := CompactionModelID(haiku, ""); got != "" {
t.Fatalf("already-cheap session must not get a dedicated summarizer, got %q", got)
}
}
func TestCompactionSummarizerFactoryTracksMainModel(t *testing.T) {
t.Setenv(CompactionModelEnv, "")
base := config.ProviderProfile{Name: "anthropic", ProviderKind: config.ProviderKindAnthropic, Model: defaultAnthropicCompactionModel}
var built []string
newProvider := func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
built = append(built, profile.Model)
return nil, errors.New("not needed")
}
factory := CompactionSummarizerFactory(base, "", newProvider)
if factory == nil {
t.Fatal("factory must exist when a provider builder exists")
}
// Main model is the cheap default: no dedicated summarizer, nothing built.
if provider, err := factory(context.Background(), defaultAnthropicCompactionModel); provider != nil || err != nil || len(built) != 0 {
t.Fatalf("cheap main model must yield (nil, nil) without building, got %v %v built=%v", provider, err, built)
}
// After escalating to an expensive model the cheap default is built.
if _, err := factory(context.Background(), "claude-sonnet-4-5"); err == nil || len(built) != 1 || built[0] != defaultAnthropicCompactionModel {
t.Fatalf("escalated main model must build the cheap summarizer, built=%v err=%v", built, err)
}
// An empty main model falls back to the base profile's model — the cheap
// default here, so again nothing is built.
built = nil
if provider, err := factory(context.Background(), ""); provider != nil || err != nil || len(built) != 0 {
t.Fatalf("empty main model must use the base profile, got %v %v built=%v", provider, err, built)
}
// The explicit "main" preference disables the dedicated summarizer.
if provider, err := CompactionSummarizerFactory(base, "main", newProvider)(context.Background(), "claude-sonnet-4-5"); provider != nil || err != nil {
t.Fatalf("preference main must disable the summarizer, got %v %v", provider, err)
}
if CompactionSummarizerFactory(base, "", nil) != nil {
t.Fatal("no provider builder means no factory")
}
}
+2
View File
@@ -7,6 +7,7 @@ import (
type BackendOptions struct {
GOOS string
LookupExecutable func(string) (string, error)
DetectWSL func() WSLInfo
}
type Backend struct {
@@ -53,6 +54,7 @@ func SelectBackend(options BackendOptions) Backend {
return NewSandboxManager(SandboxManagerOptions{
GOOS: options.GOOS,
LookupExecutable: options.LookupExecutable,
DetectWSL: options.DetectWSL,
}).Backend()
}
+15
View File
@@ -45,6 +45,7 @@ func TestSelectBackendChoosesPlatformAdapterWithFallback(t *testing.T) {
}
return "", errors.New("missing")
},
DetectWSL: func() WSLInfo { return WSLInfo{} },
})
if backend.Name != BackendUnavailable || backend.Available {
t.Fatalf("linux backend = %#v, want native sandbox unavailable without Linux helper", backend)
@@ -54,6 +55,20 @@ func TestSelectBackendChoosesPlatformAdapterWithFallback(t *testing.T) {
}
})
t.Run("linux helper missing under WSL reports WSL fallback", func(t *testing.T) {
backend := SelectBackend(BackendOptions{
GOOS: "linux",
LookupExecutable: func(string) (string, error) { return "", errors.New("missing") },
DetectWSL: func() WSLInfo { return WSLInfo{IsWSL: true, IsWSL2: true} },
})
if backend.Name != BackendWSL || backend.Available || !backend.Fallback {
t.Fatalf("linux backend = %#v, want explicit WSL fallback", backend)
}
if !strings.Contains(backend.Message, "WSL2") {
t.Fatalf("linux fallback message = %q, want WSL2 diagnostic", backend.Message)
}
})
t.Run("darwin sandbox exec available", func(t *testing.T) {
backend := SelectBackend(BackendOptions{
GOOS: "darwin",
+7 -3
View File
@@ -51,6 +51,7 @@ const (
type SandboxManagerOptions struct {
GOOS string
LookupExecutable func(string) (string, error)
DetectWSL func() WSLInfo
Backend Backend
}
@@ -96,7 +97,7 @@ func NewSandboxManager(options SandboxManagerOptions) SandboxManager {
goos = runtime.GOOS
}
if backend.Name == "" {
backend = selectPlatformBackend(goos, options.LookupExecutable)
backend = selectPlatformBackend(goos, options.LookupExecutable, options.DetectWSL)
}
if backend.Platform == "" {
backend.Platform = goos
@@ -168,10 +169,13 @@ func lookupExecutable(name string) (string, error) {
return "", errors.New("executable file not found")
}
func selectPlatformBackend(goos string, lookup func(string) (string, error)) Backend {
func selectPlatformBackend(goos string, lookup func(string) (string, error), detect func() WSLInfo) Backend {
if lookup == nil {
lookup = lookupExecutable
}
if detect == nil {
detect = detectWSL
}
switch goos {
case "linux":
if helper, err := lookup(LinuxSandboxHelperName); err == nil && helper != "" {
@@ -180,7 +184,7 @@ func selectPlatformBackend(goos string, lookup func(string) (string, error)) Bac
}
return nativeBackend(goos, BackendLinuxBwrap, helper, "Linux sandbox helper available")
}
if info := detectWSL(); info.IsWSL {
if info := detect(); info.IsWSL {
return wslBackend(goos, info)
}
return unavailableBackend(goos, "Linux sandbox helper is not available")
+239 -25
View File
@@ -19,6 +19,17 @@ type Scope struct {
workspaceRoot string
readRoots []string
extraRoots []string
// tempReads / tempWrites count how many LIVE temporary grants depend on a
// root, so one holder's cleanup cannot revoke another's access.
//
// Without them a temporary grant was add-then-remove with no notion of who
// still needed it: the second caller to ask for a root already present got a
// NO-OP undo, and the first caller's cleanup removed the root out from under
// it. Two read-only tools in the same parallel batch, both blocked on the
// same directory, is exactly that shape — and read-only tools are precisely
// the ones the batch runs concurrently.
tempReads map[string]int
tempWrites map[string]int
}
// NewScope builds a scope for workspaceRoot plus the given extra roots. The
@@ -81,15 +92,88 @@ func (s *Scope) Add(path string) (string, error) {
}
s.mu.Lock()
defer s.mu.Unlock()
for _, existing := range append([]string{s.workspaceRoot}, s.extraRoots...) {
if pathWithinRoot(existing, root) {
// COVERED BY WHAT. extraRoots holds temporary write roots alongside
// permanent ones, so "something already covers this" was not the question
// worth asking: a permanent grant made over a path a temporary holder
// happened to cover recorded nothing, and vanished the moment that holder
// released. A session-scoped grant outliving the request that prompted it is
// the entire difference between Add and AddTemporaryWrite.
//
// Permanent coverage is looked for FIRST and across every root, because a
// path can be covered twice and the temporary cover must not decide the
// answer when a permanent one is also present.
if s.permanentWriteRootCoversLocked(root) {
return root, nil
}
for _, existing := range s.extraRoots {
// The same root, held temporarily: promote it in place. Its holders'
// releases become no-ops, which is what permanence means here.
if existing == root && s.temporaryWriteLocked(existing) {
delete(s.tempWrites, root)
return root, nil
}
}
// Either uncovered, or covered only by a BROADER temporary root that is
// going to be released. Recording the narrower root in its own right is what
// survives that release.
s.extraRoots = append(s.extraRoots, root)
return root, nil
}
// temporaryWriteLocked reports whether root is held by a temporary write grant
// rather than a session-scoped one. Callers must hold the lock.
func (s *Scope) temporaryWriteLocked(root string) bool {
_, temporary := s.tempWrites[root]
return temporary
}
// permanentWriteRootCoversLocked reports whether root sits under write authority
// that outlives every temporary holder: the workspace root, or a session-scoped
// grant. Coverage by a TEMPORARY write root deliberately does not count —
// it is going to be released, so nothing recorded on the strength of it
// survives. Callers must hold the lock.
//
// One helper rather than a copy per caller because Add, AddRead and
// AddTemporaryRead all turn on the same question, and three copies of "covered,
// but by what" is how one of them ends up answering it differently.
// permanentReadRootCoversLocked reports whether root is already readable by a
// grant that outlives every temporary holder.
//
// SLICE ORDER MUST NOT DECIDE THIS. The old check walked readRoots and stopped at
// the first entry covering the path, so a BROAD TEMPORARY root sitting earlier in
// the slice shadowed a narrower PERMANENT one later — and the caller was handed a
// reference on somebody else's grant when it needed no reference at all. Asking
// the whole question first, over both lists, makes the answer independent of
// insertion order.
//
// Write roots count: a grant that permits writing permits reading the same tree.
func (s *Scope) permanentReadRootCoversLocked(root string) bool {
if s.permanentWriteRootCoversLocked(root) {
return true
}
for _, existing := range s.readRoots {
if pathWithinRoot(existing, root) && !s.temporaryReadLocked(existing) {
return true
}
}
return false
}
func (s *Scope) permanentWriteRootCoversLocked(root string) bool {
for _, existing := range append([]string{s.workspaceRoot}, s.extraRoots...) {
if pathWithinRoot(existing, root) && !s.temporaryWriteLocked(existing) {
return true
}
}
return false
}
// temporaryReadLocked is temporaryWriteLocked for read roots.
func (s *Scope) temporaryReadLocked(root string) bool {
_, temporary := s.tempReads[root]
return temporary
}
// AddRead grants read-only access under path. If the path is already covered by
// a writable root, no separate read root is stored.
func (s *Scope) AddRead(path string) (string, error) {
@@ -99,11 +183,19 @@ func (s *Scope) AddRead(path string) (string, error) {
}
s.mu.Lock()
defer s.mu.Unlock()
if s.writeRootCoversLocked(root) {
// Same rule as Add: coverage that is going to be released is not coverage a
// permanent grant can rely on, whichever side of the read/write boundary it
// sits on. A permanent read covered only by a temporary WRITE root died with
// that root just as surely.
if s.permanentWriteRootCoversLocked(root) {
return root, nil
}
if s.permanentReadRootCoversLocked(root) {
return root, nil
}
for _, existing := range s.readRoots {
if pathWithinRoot(existing, root) {
if existing == root && s.temporaryReadLocked(existing) {
delete(s.tempReads, root)
return root, nil
}
}
@@ -118,16 +210,63 @@ func (s *Scope) AddTemporaryRead(path string) (string, func(), error) {
}
s.mu.Lock()
defer s.mu.Unlock()
if s.writeRootCoversLocked(root) {
// Genuinely permanent — the workspace root, or a session-scoped grant. It
// outlives this reader, so there is nothing to release.
if s.permanentWriteRootCoversLocked(root) {
return root, func() {}, nil
}
for _, existing := range s.readRoots {
if pathWithinRoot(existing, root) {
return root, func() {}, nil
}
// A TEMPORARY write root covering this path is NOT borrowed, and this is the
// one place the distinction bites hardest.
//
// The dependency on that root is real — the write holder's cleanup must not
// revoke this reader, which is the defect the refcount exists to close,
// reached across the read/write boundary. But taking a reference on the
// WRITE root pays for the lifetime with the authority: the reference keeps
// the root in extraRoots, extraRoots is what Roots() feeds validate(), and
// validate() is WRITE authorization. So a reader outliving its writer went on
// writing anywhere under a write grant that had already ended — not merely
// under the path it asked to read.
//
// One reference cannot be both the lifetime and the capability. The lifetime
// is kept below, as a temporary READ root for the path this caller actually
// asked for. readRoots feeds ReadRoots(), which confers no write authority —
// validate() authorises writes from workspaceRoot plus extraRoots, and this
// path never enters that list. So the reader survives its writer with read
// authority and only read authority.
//
// That claim is the written justification for the whole design, so it has to
// stay exhaustive as the accessors change. It has already been wrong once, in
// both directions: an earlier draft said readRoots fed ReadRoots() and nothing
// else while ExtraReadRoots() also read it, and that accessor has since been
// removed as belonging to a different change.
if s.permanentReadRootCoversLocked(root) {
return root, func() {}, nil
}
if s.tempReads == nil {
s.tempReads = map[string]int{}
}
// THE SAME ROOT, ANOTHER HOLDER: one entry, two references. Only an EXACT
// match shares — a narrower request must not, which is the whole point below.
if _, held := s.tempReads[root]; held {
s.tempReads[root]++
return root, oncePerHolder(func() { s.releaseTemporaryRead(root) }), nil
}
// A NARROWER REQUEST RECORDS ITS OWN ROOT, even under a broader temporary
// read that currently covers it.
//
// Borrowing the covering root's reference kept this reader alive by keeping
// the BROAD ROOT alive, and readRoots feeds validateRead and the native
// sandbox profile. So once the broad reader released, a caller that had asked
// to read one subdirectory was left able to read the whole tree its neighbour
// had asked for, siblings included. Reported by @jatmn, and it is the read
// twin of the write escalation fixed above: the refcount key decides not only
// WHEN an entry disappears but WHICH path stays authorised while it lives.
s.readRoots = append(s.readRoots, root)
return root, func() { s.removeReadRoot(root) }, nil
if s.tempReads == nil {
s.tempReads = map[string]int{}
}
s.tempReads[root] = 1
return root, oncePerHolder(func() { s.releaseTemporaryRead(root) }), nil
}
func (s *Scope) AddTemporaryWrite(path string) (string, func(), error) {
@@ -137,32 +276,107 @@ func (s *Scope) AddTemporaryWrite(path string) (string, func(), error) {
}
s.mu.Lock()
defer s.mu.Unlock()
if s.writeRootCoversLocked(root) {
// A PERMANENT grant is the only cover that needs no bookkeeping: it outlives
// every temporary holder, so there is nothing to keep alive and nothing to
// release.
if s.permanentWriteRootCoversLocked(root) {
return root, func() {}, nil
}
s.extraRoots = append(s.extraRoots, root)
return root, func() { s.removeWriteRoot(root) }, nil
}
func (s *Scope) writeRootCoversLocked(root string) bool {
for _, existing := range append([]string{s.workspaceRoot}, s.extraRoots...) {
if pathWithinRoot(existing, root) {
return true
}
if s.tempWrites == nil {
s.tempWrites = map[string]int{}
}
return false
// THE SAME ROOT, ANOTHER HOLDER: one entry, two references. Only an exact
// match shares an entry — a NARROWER request must not, which is the whole
// point below.
if _, held := s.tempWrites[root]; held {
s.tempWrites[root]++
return root, oncePerHolder(func() { s.releaseTemporaryWrite(root) }), nil
}
// A NARROWER REQUEST RECORDS ITS OWN ROOT, even when a broader temporary
// grant currently covers it.
//
// Borrowing a reference on the covering root kept this holder alive, which
// was the point, but it kept the COVERING ROOT alive to do it — and
// extraRoots is what validate() authorises writes from. So once the broad
// holder released, a caller that had asked to write one subdirectory was
// left holding write authority over the whole tree its neighbour had asked
// for, including siblings it never named. Reported by CodeRabbit, and it is
// the write-side twin of the read-side escalation @jatmn found: one
// reference cannot be both a lifetime and a capability, whichever side of
// the read/write boundary it sits on.
//
// Recording the requested root separately gives each holder exactly the
// authority it asked for and a lifetime of its own. The nesting costs an
// extra entry in extraRoots while both are live, which is redundant but not
// wrong — the broader root already permits everything the narrower one does.
s.extraRoots = append(s.extraRoots, root)
s.tempWrites[root] = 1
return root, oncePerHolder(func() { s.releaseTemporaryWrite(root) }), nil
}
func (s *Scope) removeReadRoot(root string) {
// releaseTemporaryRead drops ONE holder's reference and removes the root only
// when the last one is gone.
//
// The count alone cannot make this safe, and an earlier comment here claimed it
// could — that each undo is called exactly once, and flooring at zero handled
// the rest. Flooring stops the count going negative; it does not stop one
// holder's second call consuming a DIFFERENT holder's reference. With two
// readers, calling the first's undo twice took the count 2 -> 1 -> 0 and removed
// a root the second was still using. Idempotency belongs to the closure, which
// is the thing that knows whose reference it is, so every undo handed out is
// wrapped in oncePerHolder.
func (s *Scope) releaseTemporaryRead(root string) {
s.mu.Lock()
defer s.mu.Unlock()
remaining, tracked := s.tempReads[root]
if !tracked {
s.mu.Unlock()
return
}
remaining--
if remaining > 0 {
s.tempReads[root] = remaining
s.mu.Unlock()
return
}
// Both mutations under ONE hold. Dropping the lock between them opened a
// window where the root was still in readRoots but no longer in tempReads:
// a concurrent AddTemporaryRead landing there reads it as a PERMANENT root,
// hands its caller a no-op undo, and then this call strips the root — so
// that caller believes it holds access it has already silently lost.
delete(s.tempReads, root)
s.readRoots = removeScopeRoot(s.readRoots, root)
s.mu.Unlock()
}
func (s *Scope) removeWriteRoot(root string) {
// releaseTemporaryWrite is releaseTemporaryRead for write roots. Two functions
// rather than one generic helper because they guard different slices and the
// generic version would take the slice by name — which is how the wrong one
// gets passed.
func (s *Scope) releaseTemporaryWrite(root string) {
s.mu.Lock()
defer s.mu.Unlock()
remaining, tracked := s.tempWrites[root]
if !tracked {
s.mu.Unlock()
return
}
remaining--
if remaining > 0 {
s.tempWrites[root] = remaining
s.mu.Unlock()
return
}
// Same single-hold rule as releaseTemporaryRead above.
delete(s.tempWrites, root)
s.extraRoots = removeScopeRoot(s.extraRoots, root)
s.mu.Unlock()
}
// oncePerHolder makes one holder's undo safe to call more than once. The
// reference it drops is that holder's own, so a second call must do nothing
// rather than reach into the shared count and take somebody else's.
func oncePerHolder(release func()) func() {
var once sync.Once
return func() { once.Do(release) }
}
func removeScopeRoot(roots []string, root string) []string {
+56
View File
@@ -0,0 +1,56 @@
package sandbox
import (
"path/filepath"
"testing"
)
// THE ANTI-ESCALATION PROPERTY. A read grant (AddRead, where --add-read-dir lands)
// is READABLE but NOT WRITABLE. This is what makes propagating a parent's
// request_permissions read grant to a child safe: the child can audit the granted
// path but cannot modify it. Emitting the grant as a WRITE root (--add-dir) would
// break exactly this.
func TestAReadGrantIsReadableButNotWritable(t *testing.T) {
scope, granted := grantOutsideDefaults(t)
root, err := scope.AddRead(granted)
if err != nil {
t.Fatalf("AddRead: %v", err)
}
target := filepath.Join(root, "audit-me.go")
if block := scope.validateRead(target); block != nil {
t.Fatalf("a read grant did not allow READING %q: %v — the audit would fail 'outside the workspace'", target, block)
}
if block := scope.validate(target); block == nil {
t.Fatalf("a read grant ALLOWED WRITING %q — a read grant escalated to write", target)
}
}
// grantOutsideDefaults returns a scope and a directory that scope does not
// already cover, both under test-owned storage.
//
// THE CAUSE, NOT THE SYMPTOM. NewScope seeds the system temporary directory as a
// permanent write root, so a plain t.TempDir() grant is writable before any
// grant exists and the anti-escalation assertions below prove nothing. The first
// fix placed the fixture under os.UserHomeDir() to find ground the defaults do
// not own. That worked and bought two problems: a hermetic runner may expose
// HOME read-only, so these tests failed at MkdirTemp before asserting anything,
// and on an ordinary machine they wrote into the developer's real home and left
// zero-scope-* debris whenever a run was interrupted. Reported by @jatmn.
//
// Building the Scope directly removes the seeded defaults, which is what made
// HOME necessary in the first place. t.TempDir() is then outside by
// construction, the fixture is hermetic, and nothing touches HOME.
func grantOutsideDefaults(t *testing.T) (*Scope, string) {
t.Helper()
workspace := resolvedFixturePath(t, t.TempDir())
granted := resolvedFixturePath(t, t.TempDir())
scope := &Scope{workspaceRoot: workspace}
// PROVED AGAINST THIS SCOPE. The point of the helper is that the grant is
// what changes access, so the target has to begin unauthorised — asserted
// here once rather than trusted in every caller.
if scope.validate(filepath.Join(granted, "probe.txt")) == nil {
t.Fatalf("%s is writable before any grant exists; the assertions below would prove nothing", granted)
}
return scope, granted
}
@@ -0,0 +1,595 @@
package sandbox
import (
"os"
"path/filepath"
"slices"
"sync"
"testing"
)
// scopeOutsideDefaults builds a Scope directly instead of through NewScope.
//
// NewScope adds the system temp directory as a write root, so a target under
// t.TempDir() is already covered and AddTemporaryRead returns before it ever
// touches the refcount. A test built that way exercises none of this and passes
// against the bug — which is how the first version of this test passed.
func scopeOutsideDefaults(t *testing.T) (*Scope, string) {
t.Helper()
workspace := t.TempDir()
target := filepath.Join(t.TempDir(), "shared")
if err := os.MkdirAll(target, 0o700); err != nil {
t.Fatalf("mkdir target: %v", err)
}
// Built directly, NOT via NewScope: real directories are needed because
// normalizeScopeRoot requires them to exist, but NewScope's default temp
// write root would then cover the target and short-circuit the path.
return &Scope{workspaceRoot: workspace}, target
}
// The refcount and the root list have to move together.
//
// Release used to delete the refcount, drop the lock, then strip the root in a
// second acquisition. In that window the root was still in readRoots but no
// longer in tempReads, so a concurrent AddTemporaryRead read it as a PERMANENT
// root and handed its caller a no-op undo — then the release stripped it and
// that caller silently lost access it believed it held.
func TestReleasingATemporaryReadCannotStripALiveGrant(t *testing.T) {
scope, target := scopeOutsideDefaults(t)
// First holder establishes the root.
first, releaseFirst, err := scope.AddTemporaryRead(target)
if err != nil {
t.Fatalf("AddTemporaryRead: %v", err)
}
if len(scope.tempReads) == 0 {
t.Fatal("precondition: the grant should be refcounted, not covered by a default root")
}
// Second holder takes a reference on the same root.
second, releaseSecond, err := scope.AddTemporaryRead(target)
if err != nil {
t.Fatalf("AddTemporaryRead (second): %v", err)
}
// The first holder leaving must not revoke the second's access.
releaseFirst()
if block := scope.validateRead(second); block != nil {
t.Fatalf("the second holder lost its grant when the first released: %v", block)
}
// Only the last release retires the root.
releaseSecond()
if block := scope.validateRead(first); block == nil {
t.Error("the root outlived its last holder")
}
}
// Under contention the same invariant has to hold: while a grant is live, its
// root is readable.
func TestTemporaryReadGrantsSurviveConcurrentReleases(t *testing.T) {
scope, target := scopeOutsideDefaults(t)
var wg sync.WaitGroup
var mu sync.Mutex
var failures []string
for i := 0; i < 24; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 200; j++ {
granted, undo, err := scope.AddTemporaryRead(target)
if err != nil {
mu.Lock()
failures = append(failures, "AddTemporaryRead: "+err.Error())
mu.Unlock()
return
}
if block := scope.validateRead(granted); block != nil {
mu.Lock()
failures = append(failures, "root not readable while a grant was live")
mu.Unlock()
}
undo()
}
}()
}
wg.Wait()
if len(failures) > 0 {
t.Fatalf("%d failures, first: %s", len(failures), failures[0])
}
}
// A read covered by a TEMPORARY WRITE root must take a reference too.
//
// permanentWriteRootCoversLocked scans extraRoots, and AddTemporaryWrite appends
// temporary write roots there — so "covered by a write root" does not mean
// "covered permanently". Without a reference the write holder's cleanup silently
// revoked the reader's access: the same defect the refcount closes, reached
// across the read/write boundary instead of within one side of it.
func TestAReadCoveredByATemporaryWriteSurvivesItsRelease(t *testing.T) {
workspace := t.TempDir()
outer := filepath.Join(t.TempDir(), "outer")
inner := filepath.Join(outer, "inner")
if err := os.MkdirAll(inner, 0o700); err != nil {
t.Fatal(err)
}
// Built directly for the same reason scopeOutsideDefaults is: NewScope's
// default temp write root would cover the fixture and short-circuit this.
scope := &Scope{workspaceRoot: workspace}
_, releaseWrite, err := scope.AddTemporaryWrite(outer)
if err != nil {
t.Fatalf("temporary write: %v", err)
}
readRoot, releaseRead, err := scope.AddTemporaryRead(inner)
if err != nil {
t.Fatalf("temporary read: %v", err)
}
covered := func() bool {
for _, root := range scope.ReadRoots() {
if pathWithinRoot(root, readRoot) {
return true
}
}
return false
}
if !covered() {
t.Fatal("the reader did not hold access before any release")
}
// The WRITE holder finishes first; the reader has not released.
releaseWrite()
if !covered() {
t.Errorf("the write holder's cleanup revoked a live read grant on %s", readRoot)
}
// And once the reader releases too, the root is genuinely gone.
releaseRead()
if covered() {
t.Errorf("the root outlived its last holder: %v", scope.ReadRoots())
}
}
func hasExtraRoot(scope *Scope, root string) bool {
for _, existing := range writeRootsBeyondWorkspace(scope) {
if existing == root {
return true
}
}
return false
}
// A PERMANENT GRANT MUST OUTLIVE THE TEMPORARY ONE IT LANDED ON TOP OF.
//
// extraRoots holds temporary write roots alongside permanent ones, so Add asked
// only "is this already covered" — and a session-scoped grant made while a
// temporary holder happened to cover the path recorded nothing, then vanished
// when that holder released. Outliving the request that prompted it is the
// entire difference between Add and AddTemporaryWrite.
func TestAPermanentGrantSurvivesTheTemporaryOneItCovered(t *testing.T) {
t.Run("write, same root", func(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
_, releaseTemp, err := scope.AddTemporaryWrite(outside)
if err != nil {
t.Fatal(err)
}
if _, err := scope.Add(outside); err != nil {
t.Fatal(err)
}
releaseTemp()
if !hasExtraRoot(scope, outside) {
t.Error("the temporary holder's release revoked a permanent write grant")
}
})
t.Run("read, same root", func(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
_, releaseTemp, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
if _, err := scope.AddRead(outside); err != nil {
t.Fatal(err)
}
releaseTemp()
if !hasReadRoot(scope, outside) {
t.Error("the temporary holder's release revoked a permanent read grant")
}
})
// The nested shape: a BROAD temporary root covering a NARROW permanent one.
// Promoting in place cannot help here — the narrow root has to be recorded
// in its own right, or it goes when the broad one does.
t.Run("broad temporary over narrow permanent", func(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
inner := filepath.Join(outside, "inner")
if err := os.MkdirAll(inner, 0o700); err != nil {
t.Fatal(err)
}
_, releaseBroad, err := scope.AddTemporaryWrite(outside)
if err != nil {
t.Fatal(err)
}
if _, err := scope.Add(inner); err != nil {
t.Fatal(err)
}
releaseBroad()
covered := false
for _, existing := range writeRootsBeyondWorkspace(scope) {
if pathWithinRoot(existing, inner) {
covered = true
}
}
if !covered {
t.Error("a narrower permanent grant died with the broader temporary root it sat under")
}
})
// THE READ MIRROR OF BOTH SHAPES ABOVE. AddRead makes two claims that the
// subtests so far only made for Add: the loop over extraRoots skips coverage
// that is a temporary WRITE, and the loop over readRoots skips coverage that
// is a temporary READ. Each claim is a separate branch, and neither was
// exercised from the read side.
t.Run("permanent read under a temporary write", func(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
_, releaseWrite, err := scope.AddTemporaryWrite(outside)
if err != nil {
t.Fatal(err)
}
if _, err := scope.AddRead(outside); err != nil {
t.Fatal(err)
}
releaseWrite()
if !hasReadRoot(scope, outside) {
t.Error("a temporary write holder's release revoked a permanent read grant")
}
})
t.Run("broad temporary read over narrow permanent read", func(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
inner := filepath.Join(outside, "inner")
if err := os.MkdirAll(inner, 0o700); err != nil {
t.Fatal(err)
}
_, releaseBroad, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
if _, err := scope.AddRead(inner); err != nil {
t.Fatal(err)
}
releaseBroad()
if block := scope.validateRead(filepath.Join(inner, "audit-me.go")); block != nil {
t.Errorf("a narrower permanent read died with the broader temporary read it sat under: %v", block)
}
})
}
// ONE HOLDER'S UNDO DROPS ONE HOLDER'S REFERENCE, HOWEVER OFTEN IT IS CALLED.
//
// The count flooring at zero stops it going negative; it does not stop a
// holder's second call consuming somebody else's reference. With two readers,
// calling the first's undo twice took the count 2 -> 1 -> 0 and removed a root
// the second was still using — the exact revocation this refcount exists to
// prevent, reached through a duplicate call rather than a sibling's cleanup.
// Deferred cleanups in a retry path are how a real caller does this by accident.
func TestOneHoldersUndoIsIdempotent(t *testing.T) {
scope, workspace, outside := scopeOutsideRoots(t)
_, undoFirst, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
_, undoSecond, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
undoFirst()
undoFirst()
undoFirst()
if !hasReadRoot(scope, outside) {
t.Fatal("repeated calls to one holder's undo revoked a live holder's access")
}
undoSecond()
if hasReadRoot(scope, outside) {
t.Error("the root outlived its last holder")
}
// The write side takes the same rule. Built directly for the same reason the
// helper is: NewScope would seed the system temp directory as a permanent
// write root, which already covers this fixture and makes the grant vacuous.
writeScope := &Scope{workspaceRoot: workspace}
_, undoWriteFirst, err := writeScope.AddTemporaryWrite(outside)
if err != nil {
t.Fatal(err)
}
_, undoWriteSecond, err := writeScope.AddTemporaryWrite(outside)
if err != nil {
t.Fatal(err)
}
undoWriteFirst()
undoWriteFirst()
if !hasExtraRoot(writeScope, outside) {
t.Fatal("repeated calls to one write holder's undo revoked a live holder's access")
}
undoWriteSecond()
if hasExtraRoot(writeScope, outside) {
t.Error("the write root outlived its last holder")
}
}
// temporaryWriteFixture builds a nested pair of real directories and a scope
// that covers neither: an OUTER directory a temporary write grant will take,
// and an INNER one below it that a temporary read grant will ask for.
//
// Built directly rather than through NewScope, for the reason
// scopeOutsideDefaults documents: NewScope seeds the system temp directory as a
// PERMANENT write root, which would cover the whole fixture and send every
// branch under test down the "genuinely permanent" path instead. Both paths are
// returned symlink-resolved because normalizeScopeRoot resolves what it stores,
// and on macOS the fixture's /var spelling is a symlink to /private/var.
func temporaryWriteFixture(t *testing.T) (scope *Scope, workspace, outer, inner string) {
t.Helper()
outer = filepath.Join(t.TempDir(), "outer")
inner = filepath.Join(outer, "inner")
if err := os.MkdirAll(inner, 0o700); err != nil {
t.Fatal(err)
}
workspace = resolvedFixturePath(t, t.TempDir())
return &Scope{workspaceRoot: workspace}, workspace, resolvedFixturePath(t, outer), resolvedFixturePath(t, inner)
}
func resolvedFixturePath(t *testing.T, path string) string {
t.Helper()
resolved, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatalf("resolve fixture %s: %v", path, err)
}
return resolved
}
// A READER MUST NOT INHERIT ITS WRITER'S AUTHORITY.
//
// The reader's dependency on a covering TEMPORARY WRITE root was recorded as a
// reference on that root — one reference standing for both the lifetime and the
// capability. Release the WRITER first and the reference kept the path in
// extraRoots, which is the list Roots() feeds validate(), so the surviving
// read-only holder could WRITE anywhere below a write grant that had already
// ended — including paths it never asked to read.
//
// This is TestAReadGrantIsReadableButNotWritable's property for temporary
// grants: read authority stays read authority, whichever holder leaves first.
func TestAReaderOutlivingItsWriterKeepsReadAuthorityOnly(t *testing.T) {
t.Run("the writer releases first", func(t *testing.T) {
scope, workspace, outer, inner := temporaryWriteFixture(t)
writeRoot, releaseWrite, err := scope.AddTemporaryWrite(outer)
if err != nil {
t.Fatalf("temporary write: %v", err)
}
if writeRoot != outer {
t.Fatalf("temporary write granted %q, want %q", writeRoot, outer)
}
readRoot, releaseRead, err := scope.AddTemporaryRead(inner)
if err != nil {
t.Fatalf("temporary read: %v", err)
}
if readRoot != inner {
t.Fatalf("temporary read granted %q, want %q", readRoot, inner)
}
// The reader holds a read root of its own and NO reference on the write
// root. Both halves matter: the first is what survives the writer, the
// second is what stops the write root surviving with it.
//
// Errorf, not Fatalf: these say WHY the behavior below breaks, and a
// bookkeeping check that aborts the run hides the behavior it explains.
scope.mu.RLock()
writeHolders := scope.tempWrites[outer]
readHolders := scope.tempReads[inner]
scope.mu.RUnlock()
if writeHolders != 1 {
t.Errorf("tempWrites[%s] = %d, want 1: the reader took a reference on the WRITE root", outer, writeHolders)
}
if readHolders != 1 {
t.Errorf("tempReads[%s] = %d, want 1: the reader kept no read root of its own", inner, readHolders)
}
// The WRITE holder finishes; only the read-only holder is left.
releaseWrite()
if got := writeRootsBeyondWorkspace(scope); len(got) != 0 {
t.Errorf("ExtraRoots() = %v, want none: the reader held the write root open", got)
}
if got, want := scope.ReadRoots(), []string{workspace, inner}; !slices.Equal(got, want) {
t.Errorf("ReadRoots() = %v, want %v", got, want)
}
target := filepath.Join(inner, "audit-me.go")
if block := scope.validateRead(target); block != nil {
t.Errorf("the writer's cleanup revoked a live read grant on %q: %v", target, block)
}
block := scope.validate(target)
if block == nil {
t.Fatalf("a read-only holder may still WRITE %q after the write grant ended", target)
}
if block.Code != BlockOutsideWorkspace {
t.Errorf("write block code = %q, want %q", block.Code, BlockOutsideWorkspace)
}
if block.Path != target {
t.Errorf("write block path = %q, want %q", block.Path, target)
}
// The escalation was never confined to the path the reader asked for —
// a borrowed reference carries the whole covering root — so a sibling
// the reader never named has to be denied too.
sibling := filepath.Join(outer, "sibling.txt")
if block := scope.validate(sibling); block == nil {
t.Errorf("a read-only holder of %q may still WRITE %q, which it never asked to read", inner, sibling)
}
releaseRead()
if got, want := scope.ReadRoots(), []string{workspace}; !slices.Equal(got, want) {
t.Errorf("ReadRoots() = %v, want %v: the read root outlived its last holder", got, want)
}
})
// A CONTROL, NOT COVERAGE FOR THE FINDING. This subtest passes on the
// unfixed tree too — the escalation needs the WRITER to go first, which the
// subtest above exercises. It earns its place by pinning that the opposite
// release order was not broken while fixing the dangerous one, and it is
// labelled so nobody counts it as evidence the finding is closed.
t.Run("the reader releases first (control: passes unfixed)", func(t *testing.T) {
scope, _, outer, inner := temporaryWriteFixture(t)
_, releaseWrite, err := scope.AddTemporaryWrite(outer)
if err != nil {
t.Fatalf("temporary write: %v", err)
}
_, releaseRead, err := scope.AddTemporaryRead(inner)
if err != nil {
t.Fatalf("temporary read: %v", err)
}
// The other order, which the fix must not break: the writer keeps FULL
// authority for as long as it holds the grant.
releaseRead()
buildLog := filepath.Join(outer, "build.log")
if block := scope.validate(buildLog); block != nil {
t.Fatalf("the reader's cleanup revoked the live write grant on %q: %v", buildLog, block)
}
if got, want := writeRootsBeyondWorkspace(scope), []string{outer}; !slices.Equal(got, want) {
t.Errorf("ExtraRoots() = %v, want %v", got, want)
}
releaseWrite()
if block := scope.validate(buildLog); block == nil {
t.Errorf("the write root %q outlived its only holder", outer)
}
if block := scope.validateRead(filepath.Join(inner, "audit-me.go")); block == nil {
t.Errorf("the read root %q outlived its only holder", inner)
}
})
}
// A NARROWER WRITE HOLDER MUST NOT INHERIT ITS NEIGHBOUR'S TREE.
//
// The write-side twin of the read-side escalation above, and the same root
// cause: a nested holder used to take a reference on the BROADER temporary root
// that happened to cover it. That kept the nested holder alive, which was the
// point, but it kept the covering root alive to do it — and extraRoots is what
// validate() authorises writes from. Once the broad holder released, a caller
// that had asked for one subdirectory held write authority over the whole tree,
// siblings included. Reported by CodeRabbit.
func TestANestedWriteHolderKeepsOnlyItsOwnSubtree(t *testing.T) {
scope, _, outsideBase := scopeOutsideRoots(t)
outer := filepath.Join(outsideBase, "outer")
inner := filepath.Join(outer, "inner")
sibling := filepath.Join(outer, "sibling")
for _, dir := range []string{outer, inner, sibling} {
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
}
_, releaseOuter, err := scope.AddTemporaryWrite(outer)
if err != nil {
t.Fatalf("temporary write on the broad root: %v", err)
}
_, releaseInner, err := scope.AddTemporaryWrite(inner)
if err != nil {
t.Fatalf("temporary write on the nested root: %v", err)
}
// THE BROAD HOLDER GOES FIRST — the nested holder outliving it is what
// exposes whose authority it is actually holding.
releaseOuter()
if block := scope.validate(filepath.Join(inner, "mine.txt")); block != nil {
t.Errorf("the nested holder lost the subtree it asked for: %v", block.Reason)
}
if block := scope.validate(filepath.Join(sibling, "not-mine.txt")); block == nil {
t.Error("the nested holder can write a SIBLING it never asked for: the broad holder's authority outlived the broad holder")
}
if block := scope.validate(filepath.Join(outer, "not-mine.txt")); block == nil {
t.Error("the nested holder can write the covering root itself after its holder released")
}
releaseInner()
if block := scope.validate(filepath.Join(inner, "mine.txt")); block == nil {
t.Error("the nested root outlived its only holder")
}
}
// The opposite order, as a control: the nested holder releasing first must not
// disturb the broad holder that is still live.
func TestANestedWriteHolderReleasingFirstLeavesTheBroadGrant(t *testing.T) {
scope, _, outsideBase := scopeOutsideRoots(t)
outer := filepath.Join(outsideBase, "outer")
inner := filepath.Join(outer, "inner")
for _, dir := range []string{outer, inner} {
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
}
_, releaseOuter, err := scope.AddTemporaryWrite(outer)
if err != nil {
t.Fatal(err)
}
_, releaseInner, err := scope.AddTemporaryWrite(inner)
if err != nil {
t.Fatal(err)
}
releaseInner()
if block := scope.validate(filepath.Join(inner, "still-ok.txt")); block != nil {
t.Errorf("the nested holder's release revoked the broad holder's coverage: %v", block.Reason)
}
releaseOuter()
if block := scope.validate(filepath.Join(inner, "gone.txt")); block == nil {
t.Error("the tree outlived every holder")
}
}
// TWO HOLDERS OF THE SAME ROOT SHARE ONE ENTRY. Only an exact match shares —
// a narrower request must not, which is what the escalation test above pins.
func TestTwoHoldersOfTheSameWriteRootShareOneEntry(t *testing.T) {
scope, _, outsideBase := scopeOutsideRoots(t)
target := filepath.Join(outsideBase, "shared")
if err := os.MkdirAll(target, 0o700); err != nil {
t.Fatal(err)
}
_, releaseFirst, err := scope.AddTemporaryWrite(target)
if err != nil {
t.Fatal(err)
}
_, releaseSecond, err := scope.AddTemporaryWrite(target)
if err != nil {
t.Fatal(err)
}
releaseFirst()
if block := scope.validate(filepath.Join(target, "still-held.txt")); block != nil {
t.Errorf("one holder's release revoked the other's grant on the same root: %v", block.Reason)
}
releaseSecond()
if block := scope.validate(filepath.Join(target, "gone.txt")); block == nil {
t.Error("the shared root outlived both holders")
}
}
// writeRootsBeyondWorkspace is Roots() without the workspace entry.
//
// These tests need to see which BEYOND-WORKSPACE write roots a scope currently
// holds. There was an ExtraRoots() accessor that answered exactly this, but it
// had no production caller — it belongs to #829's child-grant propagation, whose
// consumer side is not on this branch, and @jatmn was right that carrying it here
// leaves unrelated production surface in a PR scoped to refcounting. The
// inspection is a test concern, so it lives in the tests.
func writeRootsBeyondWorkspace(scope *Scope) []string {
all := scope.Roots()
if len(all) == 0 {
return nil
}
return append([]string(nil), all[1:]...)
}
+366
View File
@@ -0,0 +1,366 @@
package sandbox
import (
"os"
"path/filepath"
"sync"
"testing"
)
// scopeOutsideRoots builds a workspace and an unrelated directory that no
// default write root already covers.
//
// NOT t.TempDir(). /tmp and $TMPDIR are default write roots, so a temporary
// grant for a path under one is a no-op — every assertion here would pass
// against code that grants nothing at all. That is RULES §2.3 in its exact
// form, and it caught the first version of this test.
// scopeOutsideRoots builds a workspace and a directory outside it, both under
// test-owned storage, and returns a Scope that treats the second as genuinely
// outside.
//
// THE FIXTURE PROBLEM AND ITS ACTUAL CAUSE. NewScope seeds the system temporary
// directory as a permanent write root, so an ordinary t.TempDir() grant is
// covered before any temporary grant exists and every assertion built on it is
// vacuous. The first fix for that reached into os.UserHomeDir() to find ground
// the defaults do not already own — which worked, and bought two new problems:
// a hermetic runner may expose HOME read-only, so the tests failed at MkdirTemp
// before asserting anything; and on an ordinary machine they wrote fixtures into
// the developer's real home and left zeromax-scope-* debris behind whenever a
// run was interrupted. Reported by @jatmn.
//
// The cause is the seeded defaults, not the location, so the Scope is built
// DIRECTLY here — the same pattern scopeOutsideDefaults and temporaryWriteFixture
// already use. With no defaults seeded, t.TempDir() is outside by construction,
// the fixture is hermetic, and nothing touches HOME.
//
// Paths are symlink-resolved because the scope stores resolved roots and macOS
// hands out /var/... temp directories that resolve to /private/var/...; an
// unresolved fixture path compares unequal to the root the scope just recorded.
func scopeOutsideRoots(t *testing.T) (scope *Scope, workspace string, outside string) {
t.Helper()
workspace = resolvedFixturePath(t, t.TempDir())
outside = filepath.Join(resolvedFixturePath(t, t.TempDir()), "outside")
// FATAL, NOT SKIP. Every caller of this helper asserts an AUTHORIZATION
// boundary, so a skipped fixture means that assertion never ran and the build
// reported green anyway. t.TempDir() already failed the test if it could not
// provide storage, so a subdirectory failing under it is a broken machine.
if err := os.MkdirAll(outside, 0o700); err != nil {
t.Fatalf("cannot prepare %s: %v", outside, err)
}
scope = &Scope{workspaceRoot: workspace}
// PROVED AGAINST THIS SCOPE, not against production's default list. The point
// of the helper is that a grant here changes something, and the only thing
// that can answer that is the scope the test will actually use. Checking
// defaultTempWriteRoots() instead asks about a scope nobody builds here — and
// says "prove nothing" for a t.TempDir() path that this scope, seeded with no
// defaults, genuinely does not cover.
//
// Asserting the NEGATIVE up front is also what keeps every caller honest: it
// establishes that the target begins unauthorised, so a later success is the
// grant's doing and not the fixture's placement.
if scope.validate(filepath.Join(outside, "probe.txt")) == nil {
t.Fatalf("%s is writable before any grant exists, so a temporary grant here would prove nothing", outside)
}
if scope.validateRead(filepath.Join(outside, "probe.txt")) == nil {
t.Fatalf("%s is readable before any grant exists, so a temporary read grant here would prove nothing", outside)
}
return scope, workspace, outside
}
func hasReadRoot(scope *Scope, root string) bool {
for _, existing := range scope.ReadRoots() {
if existing == root {
return true
}
}
return false
}
// THE DEFECT: one holder's cleanup revoked another's access. Two read-only
// tools in the same parallel batch, both blocked on the same directory, is
// exactly this — and read-only tools are the ones the batch runs concurrently.
func TestATemporaryReadSurvivesASiblingsCleanup(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
// The probe that the fix must make unnecessary: without it this reads
// true -> false.
if hasReadRoot(scope, outside) {
t.Fatal("the outside root is already covered; this test proves nothing")
}
_, undoA, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
_, undoB, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
if !hasReadRoot(scope, outside) {
t.Fatal("the grant did not take effect")
}
undoA()
if !hasReadRoot(scope, outside) {
t.Fatal("A's cleanup revoked the root while B still held a grant")
}
undoB()
if hasReadRoot(scope, outside) {
t.Fatal("the root outlived its last holder")
}
}
// A NARROWER REQUEST KEEPS ITS OWN SUBTREE AND NOTHING WIDER.
//
// THIS TEST USED TO ASSERT THE OPPOSITE, and the earlier version is worth
// recording because it is how the escalation survived a review round. It was
// written to prove a LIFETIME property — the broad holder's cleanup must not
// revoke a live narrow holder — and it proved that by asserting the BROAD root
// was still present afterwards. That is the defect stated as a requirement: the
// narrow holder was keeping its neighbour's whole tree alive in order to keep
// itself alive, so the surviving reader could read siblings it never asked for.
//
// Lifetime and capability are two properties and one reference cannot carry
// both. The assertions below name them separately: the requested path stays
// readable while its holder lives, AND a sibling under the released broad root
// is denied as soon as the holder that authorised it exits. Reported by @jatmn.
func TestANarrowerReaderKeepsOnlyItsOwnSubtree(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
nested := filepath.Join(outside, "nested")
sibling := filepath.Join(outside, "sibling")
for _, dir := range []string{nested, sibling} {
// FATAL, NOT SKIP. These directories are the fixture, not a precondition
// the environment might reasonably withhold — skipping on them reports a
// broken setup as a passing run.
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
}
_, undoBroad, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
_, undoNarrow, err := scope.AddTemporaryRead(nested)
if err != nil {
t.Fatal(err)
}
// THE BROAD HOLDER GOES FIRST. The narrow holder outliving it is what exposes
// whose authority it is actually holding.
undoBroad()
if block := scope.validateRead(filepath.Join(nested, "mine.txt")); block != nil {
t.Errorf("the broad holder's cleanup revoked the subtree the narrow holder asked for: %v", block.Reason)
}
if block := scope.validateRead(filepath.Join(sibling, "not-mine.txt")); block == nil {
t.Error("the narrow reader can read a SIBLING it never asked for: the broad holder's authority outlived the broad holder")
}
if block := scope.validateRead(filepath.Join(outside, "not-mine.txt")); block == nil {
t.Error("the narrow reader can read the covering root itself after its holder released")
}
undoNarrow()
if block := scope.validateRead(filepath.Join(nested, "mine.txt")); block == nil {
t.Error("the nested root outlived its only holder")
}
}
// The opposite order, as a control: the narrow reader releasing first must not
// disturb the broad reader that is still live.
func TestANarrowerReaderReleasingFirstLeavesTheBroadGrant(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
nested := filepath.Join(outside, "nested")
if err := os.MkdirAll(nested, 0o700); err != nil {
t.Fatal(err)
}
_, undoBroad, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
_, undoNarrow, err := scope.AddTemporaryRead(nested)
if err != nil {
t.Fatal(err)
}
undoNarrow()
if block := scope.validateRead(filepath.Join(nested, "still-ok.txt")); block != nil {
t.Errorf("the narrow holder's release revoked the broad holder's coverage: %v", block.Reason)
}
undoBroad()
if block := scope.validateRead(filepath.Join(nested, "gone.txt")); block == nil {
t.Error("the tree outlived every holder")
}
}
// TWO READERS OF THE SAME ROOT SHARE ONE ENTRY. Only an exact match shares; a
// narrower one never does, which is what the escalation test above pins.
func TestTwoReadersOfTheSameRootShareOneEntry(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
_, undoFirst, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
_, undoSecond, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
undoFirst()
if block := scope.validateRead(filepath.Join(outside, "still-held.txt")); block != nil {
t.Errorf("one reader's release revoked the other's grant on the same root: %v", block.Reason)
}
undoSecond()
if block := scope.validateRead(filepath.Join(outside, "gone.txt")); block == nil {
t.Error("the shared root outlived both holders")
}
}
// A NARROW PERMANENT READ IS NOT BORROWED FROM A BROAD TEMPORARY ONE. The old
// scan walked readRoots in order and stopped at the first entry covering the
// path, so a broad TEMPORARY root earlier in the slice shadowed a narrower
// PERMANENT one later — and the caller took a reference it did not need on a
// grant that was about to end.
func TestAPermanentReadIsNotShadowedByABroadTemporaryOne(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
nested := filepath.Join(outside, "nested")
if err := os.MkdirAll(nested, 0o700); err != nil {
t.Fatal(err)
}
// The broad TEMPORARY grant is taken first, so it sits earlier in readRoots.
_, undoBroad, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
if _, err := scope.AddRead(nested); err != nil {
t.Fatal(err)
}
undoBroad()
if block := scope.validateRead(filepath.Join(nested, "mine.txt")); block != nil {
t.Errorf("a PERMANENT read grant died with a broad temporary one that merely preceded it: %v", block.Reason)
}
}
// A PERMANENT root is not refcounted and must never be removed by a temporary
// holder's cleanup — the undo for a request it already covers is genuinely
// nothing.
func TestATemporaryGrantNeverRevokesAPermanentRoot(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
if _, err := scope.AddRead(outside); err != nil {
t.Fatal(err)
}
_, undo, err := scope.AddTemporaryRead(outside)
if err != nil {
t.Fatal(err)
}
undo()
if !hasReadRoot(scope, outside) {
t.Fatal("a temporary holder's cleanup removed a permanent read root")
}
}
// The same rule for WRITE roots, or a write grant keeps the bug reads no longer
// have.
func TestATemporaryWriteSurvivesASiblingsCleanup(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
covered := func() bool {
for _, existing := range scope.Roots() {
if existing == outside {
return true
}
}
return false
}
_, undoA, err := scope.AddTemporaryWrite(outside)
if err != nil {
t.Fatal(err)
}
_, undoB, err := scope.AddTemporaryWrite(outside)
if err != nil {
t.Fatal(err)
}
if !covered() {
t.Fatal("the write grant did not take effect")
}
undoA()
if !covered() {
t.Fatal("A's cleanup revoked the write root while B still held a grant")
}
undoB()
if covered() {
t.Fatal("the write root outlived its last holder")
}
}
// UNDER REAL CONCURRENCY, which is the shape the parallel tool batch produces:
// eight holders taking and releasing the same root in overlapping windows. The
// root must be present the whole time at least one holder has it, and gone once
// the last releases.
func TestConcurrentHoldersOfOneRoot(t *testing.T) {
scope, _, outside := scopeOutsideRoots(t)
const holders = 8
var wg sync.WaitGroup
start := make(chan struct{})
release := make(chan struct{})
// TWO PER HOLDER, because each one can report at both hasReadRoot checks. At
// one slot per holder the channel fills after eight reports, the ninth send
// blocks forever, wg.Done() is never reached and wg.Wait() hangs — so the
// test would DEADLOCK rather than fail in exactly the case it exists to
// catch, since nothing drains failures until after the wait.
failures := make(chan string, 2*holders)
// Signalled by EVERY holder once its own AddTemporaryRead has returned,
// failure included: the gate below waits for all of them, so a holder that
// returned early without signalling would hang this test rather than fail it.
acquired := make(chan struct{}, holders)
for i := 0; i < holders; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
_, undo, err := scope.AddTemporaryRead(outside)
acquired <- struct{}{}
if err != nil {
failures <- err.Error()
return
}
// Every holder must SEE its own grant for as long as it holds it.
if !hasReadRoot(scope, outside) {
failures <- "a holder could not see the root it had just been granted"
}
<-release
if !hasReadRoot(scope, outside) {
failures <- "a holder lost the root while still holding it"
}
undo()
}()
}
close(start)
// EVERY holder, not the first one. Spinning on hasReadRoot only waited for
// somebody to hold the root, and the spin is tight enough to win that race
// against the goroutines still being scheduled: measured over 200 runs, 194
// of them peaked at a single simultaneous holder and none ever reached
// eight. A test named for concurrent holders was testing one holder at a
// time, and would have passed against an implementation with no refcount at
// all. Counting the acquisitions makes the overlap real instead of hoped for.
for i := 0; i < holders; i++ {
<-acquired
}
close(release)
wg.Wait()
close(failures)
for failure := range failures {
t.Fatal(failure)
}
if hasReadRoot(scope, outside) {
t.Fatal("the root outlived every holder")
}
// AND THE BOOKKEEPING IS EMPTY, not merely invisible. hasReadRoot answers
// what a caller can see; a refcount entry left behind at zero is not visible
// that way and would still leak, one map entry per acquire/release cycle.
scope.mu.Lock()
leftover, stillCounted := scope.tempReads[outside]
scope.mu.Unlock()
if stillCounted {
t.Fatalf("the refcount table kept an entry for %q after every holder released: %v", outside, leftover)
}
}
-15
View File
@@ -1,15 +0,0 @@
//go:build !windows
package swarm
import (
"errors"
"os"
)
// isLockContended reports whether an O_EXCL lock-create error means the lock is
// currently held (so the caller should retry/wait) rather than a hard failure.
// On POSIX an existing lock surfaces only as os.ErrExist.
func isLockContended(err error) bool {
return errors.Is(err, os.ErrExist)
}
@@ -0,0 +1,18 @@
//go:build !windows
package swarm
import (
"os"
"path/filepath"
"testing"
)
func makeRedirectedLockPath(t *testing.T, root, target string) string {
t.Helper()
lockPath := filepath.Join(root, "redirected.lock")
if err := os.Symlink(target, lockPath); err != nil {
t.Fatalf("create redirected lock path: %v", err)
}
return lockPath
}
@@ -0,0 +1,20 @@
//go:build windows
package swarm
import (
"os/exec"
"path/filepath"
"strings"
"testing"
)
func makeRedirectedLockPath(t *testing.T, root, target string) string {
t.Helper()
linkDir := filepath.Join(root, "redirected")
out, err := exec.Command("cmd", "/c", "mklink", "/J", linkDir, filepath.Dir(target)).CombinedOutput()
if err != nil {
t.Fatalf("create redirected lock junction: %v %s", err, strings.TrimSpace(string(out)))
}
return filepath.Join(linkDir, filepath.Base(target))
}
-30
View File
@@ -1,30 +0,0 @@
//go:build windows
package swarm
import (
"errors"
"os"
"syscall"
)
// Windows error codes that an O_EXCL create (or a racing delete) can surface
// when another writer holds the lock file open, instead of ERROR_FILE_EXISTS.
// Treating them as "contended" makes the lock retry/wait rather than fail the
// whole send/read spuriously under contention.
const (
errorAccessDenied = syscall.Errno(5) // ERROR_ACCESS_DENIED
errorSharingViolation = syscall.Errno(32) // ERROR_SHARING_VIOLATION
)
// isLockContended reports whether an O_EXCL lock-create error means the lock is
// currently held (so the caller should retry/wait) rather than a hard failure.
// On Windows a held lock can appear as ERROR_FILE_EXISTS (mapped to os.ErrExist)
// but also as ERROR_ACCESS_DENIED / ERROR_SHARING_VIOLATION when the file is
// concurrently open or pending deletion.
func isLockContended(err error) bool {
if errors.Is(err, os.ErrExist) {
return true
}
return errors.Is(err, errorAccessDenied) || errors.Is(err, errorSharingViolation)
}
+12 -58
View File
@@ -8,7 +8,6 @@ import (
"path/filepath"
"regexp"
"strings"
"sync/atomic"
"time"
"github.com/Gitlawb/zero/internal/fsutil"
@@ -17,10 +16,10 @@ import (
// Mailbox is a per-agent, per-team message inbox persisted as a JSON array on
// disk at <baseDir>/<team>/inboxes/<agent>.json, written atomically under a
// lock file. It is hardened for untrusted input:
// advisory lock. It is hardened for untrusted input:
//
// - inbox files and their parent dirs are owner-only (0600/0700);
// - every write takes an exclusive lock file (bounded retry, stale-break);
// - every write takes an exclusive advisory lock (bounded retry);
// - message bodies and whole inboxes are size-capped (fail closed on oversize);
// - team/agent names are sanitized so a name can never escape the base dir;
// - malformed inbox JSON is rejected rather than silently reset (fail closed).
@@ -44,7 +43,6 @@ const (
defaultMaxMessageBytes = 64 * 1024 // 64 KiB per message
defaultMaxMessages = 1000 // per inbox
defaultLockTimeout = 5 * time.Second
lockStaleAfter = 30 * time.Second
inboxFileMode = 0o600
inboxDirMode = 0o700
)
@@ -206,7 +204,7 @@ func (m *Mailbox) Send(team, recipient string, msg Message) error {
if err := m.ensureInboxDir(path); err != nil {
return err
}
release, err := acquireLock(path+".lock", m.lockTimeout())
release, err := acquireLock(m.BaseDir, path+".lock", m.lockTimeout())
if err != nil {
return err
}
@@ -233,12 +231,12 @@ func (m *Mailbox) ReadAndConsume(team, recipient string) ([]Message, error) {
if err != nil {
return nil, err
}
// The lock file lives beside the inbox; create the dir so it can be taken
// The stable lock file lives beside the inbox; create the dir so it can be taken
// even on first read of a not-yet-existing inbox.
if err := m.ensureInboxDir(path); err != nil {
return nil, err
}
release, err := acquireLock(path+".lock", m.lockTimeout())
release, err := acquireLock(m.BaseDir, path+".lock", m.lockTimeout())
if err != nil {
return nil, err
}
@@ -334,63 +332,19 @@ func (m *Mailbox) atomicWriteJSON(path string, data any) error {
return nil
}
// lockSeq makes each lock token unique within the process even when two
// goroutines acquire in the same nanosecond.
var lockSeq atomic.Uint64
// acquireLock takes an exclusive lock by creating lockPath with O_EXCL. It
// retries with a short backoff until timeout, breaking a lock whose file is
// older than lockStaleAfter (so a crashed holder cannot deadlock the swarm).
//
// Each holder writes a unique token into the lock file and release is
// OWNERSHIP-AWARE: it removes the lock only if it still holds our token. After a
// stale-break another writer may legitimately own the lock; unconditionally
// removing it would let a third writer in and create split-brain writers that
// can corrupt or drop mailbox updates.
func acquireLock(lockPath string, timeout time.Duration) (func(), error) {
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), time.Now().UnixNano(), lockSeq.Add(1))
// acquireLock takes an exclusive advisory lock through a stable lock file. It
// retries with a short backoff until timeout. The kernel releases the lock when
// a holder exits, so crash recovery never moves or deletes the canonical path.
func acquireLock(root, lockPath string, timeout time.Duration) (func(), error) {
deadline := time.Now().Add(timeout)
for {
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, inboxFileMode)
lock, err := lockutil.TryAcquireFileLockAt(root, lockPath)
if err == nil {
// Record our ownership token; the exclusive create already holds the
// lock, so a write error only weakens diagnostics, not safety.
_, _ = f.WriteString(token)
f.Close()
var released bool
return func() {
if released {
return
}
released = true
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
_ = lockutil.RemoveLockFile(lockPath)
}
}, nil
return func() { _ = lock.Release() }, nil
}
if !isLockContended(err) {
if !errors.Is(err, lockutil.ErrLockHeld) {
return nil, fmt.Errorf("swarm: acquire lock: %w", err)
}
// Lock held: break it if stale via the atomic rename-with-verify in
// lockutil.ReclaimStaleLock (AUDIT-M13), otherwise wait.
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > lockStaleAfter {
cleared, rerr := lockutil.ReclaimStaleLock(lockPath, token, func(reclaimedPath string) bool {
info, err := os.Stat(reclaimedPath)
return err == nil && time.Since(info.ModTime()) <= lockStaleAfter
})
if rerr != nil {
// Reclaim hit a hard failure: the rename aside failed outright, or a
// live holder's lock could not be put back (the lock path may be
// missing, so re-acquiring would break mutual exclusion). Fail closed
// instead of spinning to the deadline.
return nil, fmt.Errorf("swarm: reclaim stale lock: %w", rerr)
}
if cleared {
continue // cleared a genuinely stale lock; retry the O_EXCL create now
}
// Lost the reclaim race (or it was actually fresh) — fall through to the
// bounded wait instead of hot-spinning on a reclaim that never wins.
}
if time.Now().After(deadline) {
return nil, fmt.Errorf("swarm: timed out acquiring lock %s", filepath.Base(lockPath))
}
+38 -27
View File
@@ -219,64 +219,75 @@ func TestMailboxRejectsSymlinkedInboxDir(t *testing.T) {
}
}
func TestMailboxLockReleaseIsOwnershipAware(t *testing.T) {
func TestMailboxLockReleaseKeepsStablePath(t *testing.T) {
mb := newTestMailbox(t)
path, _ := mb.inboxPath("team", "bob")
if err := mb.ensureInboxDir(path); err != nil {
t.Fatalf("ensureInboxDir: %v", err)
}
lockPath := path + ".lock"
// Writer A acquires the lock.
releaseA, err := acquireLock(lockPath, time.Second)
releaseA, err := acquireLock(mb.BaseDir, lockPath, time.Second)
if err != nil {
t.Fatalf("acquire A: %v", err)
}
// Simulate a stale-break + takeover by writer B: overwrite the lock content
// with B's token (as a fresh acquire after a break would).
if err := os.WriteFile(lockPath, []byte("writer-B-token"), 0o600); err != nil {
t.Fatalf("simulate B takeover: %v", err)
}
// A's release must NOT delete B's lock (ownership-aware).
releaseA()
if _, err := os.Stat(lockPath); err != nil {
t.Fatalf("A's release deleted B's lock (split-brain): %v", err)
t.Fatalf("release removed stable advisory-lock path: %v", err)
}
// B's own release removes it.
os.Remove(lockPath)
}
func TestAcquireLockReclaimsStaleLock(t *testing.T) {
// A crashed holder's stale lock (old mtime) must be reclaimed via the atomic
// rename-with-verify path, leaving no sidelined .stale.* file (AUDIT-M13).
func TestAcquireLockIgnoresOldUnlockedFile(t *testing.T) {
// A crashed holder can leave an old file behind, but the kernel releases its
// advisory lock. Acquisition must succeed without renaming the stable path.
lockPath := filepath.Join(t.TempDir(), "x.lock")
if err := os.WriteFile(lockPath, []byte("dead-holder"), 0o600); err != nil {
t.Fatalf("seed stale lock: %v", err)
t.Fatalf("seed old lock metadata: %v", err)
}
old := time.Now().Add(-2 * lockStaleAfter)
old := time.Now().Add(-time.Hour)
if err := os.Chtimes(lockPath, old, old); err != nil {
t.Fatalf("chtimes: %v", err)
}
release, err := acquireLock(lockPath, time.Second)
release, err := acquireLock(filepath.Dir(lockPath), lockPath, time.Second)
if err != nil {
t.Fatalf("acquireLock should reclaim a genuinely stale lock, got %v", err)
t.Fatalf("acquire over old unlocked file: %v", err)
}
release()
if matches, _ := filepath.Glob(lockPath + ".stale.*"); len(matches) != 0 {
t.Fatalf("reclaim left sidelined files: %v", matches)
if _, err := os.Stat(lockPath); err != nil {
t.Fatalf("stable lock path missing after release: %v", err)
}
}
func TestAcquireLockDoesNotBreakFreshLock(t *testing.T) {
// A fresh, held lock (recent mtime) must never be broken — the stale-break must
// not steal a live lock (AUDIT-M13).
func TestAcquireLockDoesNotBreakHeldLock(t *testing.T) {
// File age is irrelevant while the kernel reports an active holder.
lockPath := filepath.Join(t.TempDir(), "x.lock")
release, err := acquireLock(lockPath, time.Second)
release, err := acquireLock(filepath.Dir(lockPath), lockPath, time.Second)
if err != nil {
t.Fatalf("first acquire: %v", err)
}
defer release()
if _, err := acquireLock(lockPath, 50*time.Millisecond); err == nil {
t.Fatal("acquireLock broke a fresh, held lock (split-brain risk)")
if _, err := acquireLock(filepath.Dir(lockPath), lockPath, 50*time.Millisecond); err == nil {
t.Fatal("acquireLock admitted a second holder")
}
}
func TestAcquireLockRefusesRedirectedPath(t *testing.T) {
root := t.TempDir()
target := filepath.Join(t.TempDir(), "target")
const sentinel = "do not overwrite"
if err := os.WriteFile(target, []byte(sentinel), 0o600); err != nil {
t.Fatal(err)
}
lockPath := makeRedirectedLockPath(t, root, target)
if release, err := acquireLock(root, lockPath, time.Second); err == nil {
release()
t.Fatal("acquireLock followed a redirected lock path")
}
data, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if string(data) != sentinel {
t.Fatalf("redirected target was modified: %q", data)
}
}
+20 -8
View File
@@ -140,7 +140,6 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any
return errorResult(fileUnseenMessage(relativePath))
}
previouslySeenWhole := options.FileTracker.SeenWhole(absolutePath)
updated := strings.Replace(content, oldString, newString, 1)
replacedCount := 1
if replaceAll {
@@ -165,15 +164,28 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any
// Re-baseline to the content we just wrote so subsequent edits in this session
// compare against the current on-disk state, not the pre-edit version.
newInfo, _ := os.Stat(absolutePath)
options.FileTracker.Record(absolutePath, []byte(updated), newInfo)
if updated == modelKnownContent {
if previouslySeenWhole {
options.FileTracker.RecordSeenRange(absolutePath, 1, trackedLineTotal(updated), trackedLineTotal(updated))
} else {
for _, span := range editedSpans {
options.FileTracker.RecordSeenBytes(absolutePath, span.start, span.end, len(updated))
}
// OUR edit, so we know precisely which lines moved: RecordEdit carries
// across the reads this edit did not disturb instead of dropping them.
//
// Record would drop all of them, and did — a file read in three pieces
// lost every piece to a single two-line edit, and the next six edits into
// regions that had been read were refused as unseen. See RecordEdit.
//
// This SUBSUMES the previouslySeenWhole special-case #956 added here.
// That branch re-recorded 1..total when the file had been read whole;
// RecordEdit does the same thing one level down (its seenWhole arm
// re-baselines as a single covering observation) and additionally keeps
// the partial reads the old else-branch discarded. Two copies of the
// rule would drift, and only one of them sees the pre-edit observation.
options.FileTracker.RecordEdit(absolutePath, []byte(content), []byte(updated), newInfo)
for _, span := range editedSpans {
options.FileTracker.RecordSeenBytes(absolutePath, span.start, span.end, len(updated))
}
} else {
// A formatter rewrote the file after us. We no longer know which line
// holds what was read, so the conservative drop is the right answer here.
options.FileTracker.Record(absolutePath, []byte(updated), newInfo)
}
suffix := ""
+296
View File
@@ -0,0 +1,296 @@
package tools
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
// editTrackerFixture writes a numbered file and returns its real path plus a
// tracker that has already recorded the whole content as the current version.
//
// EvalSymlinks matters: on macOS t.TempDir() hands back /var/..., the tools
// resolve it to /private/var/..., and a tracker keyed on the unresolved path
// silently misses every lookup — which makes an edit fail for the very reason
// this file is about, and for entirely the wrong cause.
func editTrackerFixture(t *testing.T, lines []string) (string, *FileTracker, Tool) {
t.Helper()
root, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
path := filepath.Join(root, "index.go")
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
t.Fatal(err)
}
tracker := NewFileTracker()
content, _ := os.ReadFile(path)
info, _ := os.Stat(path)
tracker.Record(path, content, info)
return path, tracker, NewScopedEditFileTool(root, nil)
}
func numberedLines(total int, special map[int]string) []string {
lines := make([]string, 0, total)
for i := 1; i <= total; i++ {
if text, ok := special[i]; ok {
lines = append(lines, text)
continue
}
lines = append(lines, "filler")
}
return lines
}
func runTrackedEdit(t *testing.T, tool Tool, tracker *FileTracker, path, oldString, newString string) Result {
t.Helper()
return tool.(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
"path": path, "old_string": oldString, "new_string": newString,
}, RunOptions{FileTracker: tracker})
}
// ONE EDIT MUST NOT ERASE EVERY OTHER READ, and this is the run that made the
// point: a 371-line file read in three pieces (40-45, 85-260, 260-371), one
// two-line edit at line 92, and then six consecutive refusals to edit regions
// that HAD been read and that the edit never touched — until the
// repeated-failure guard halted the run. The error told the model to re-read,
// which its next successful edit would have undone again.
func TestASuccessfulEditKeepsTheReadsItDidNotDisturb(t *testing.T) {
path, tracker, tool := editTrackerFixture(t, numberedLines(371, map[int]string{
92: "cmp := keyCmp(k, n.split)",
200: "prio: l.prio, split: l.split,",
}))
tracker.RecordSeenRange(path, 40, 45, 371)
tracker.RecordSeenRange(path, 85, 260, 371)
tracker.RecordSeenRange(path, 260, 371, 371)
if !tracker.SeenRange(path, 200, 200) {
t.Fatal("setup: line 200 sits inside the 85-260 read and must start out seen")
}
first := runTrackedEdit(t, tool, tracker, path, "cmp := keyCmp(k, n.split)", "cmp := keyCmp(k, n.pivot)")
if strings.Contains(first.Output, "Error") {
t.Fatalf("the first edit failed, so the test never reaches its subject: %s", first.Output)
}
if !tracker.SeenRange(path, 200, 200) {
t.Error("line 200 was read, the edit at line 92 did not touch it, and the file still has 371 lines — but it is no longer considered seen")
}
// The assertion that matters: the SECOND edit is what the run could never do.
second := runTrackedEdit(t, tool, tracker, path, "prio: l.prio, split: l.split,", "prio: l.prio, pivot: l.pivot,")
if strings.Contains(second.Output, "not been read exactly in this session") {
t.Fatalf("a second edit into an already-read region was refused as unseen: %s", second.Output)
}
if strings.Contains(second.Output, "Error") {
t.Fatalf("the second edit failed: %s", second.Output)
}
}
// LINE NUMBERS MOVE WHEN AN EDIT CHANGES THE LINE COUNT, so a range after the
// edit has to be carried across SHIFTED, not left where it was.
func TestReadsAfterAnEditAreShiftedByTheLineDelta(t *testing.T) {
path, tracker, tool := editTrackerFixture(t, numberedLines(100, map[int]string{
10: "short",
80: "target line",
}))
tracker.RecordSeenRange(path, 5, 20, 100)
tracker.RecordSeenRange(path, 70, 90, 100)
// Replace one line with three: everything after line 10 moves down by two.
res := runTrackedEdit(t, tool, tracker, path, "short", "one\ntwo\nthree")
if strings.Contains(res.Output, "Error") {
t.Fatalf("setup edit failed: %s", res.Output)
}
// "target line" was line 80 and is now line 82. It was read either way.
content, _ := os.ReadFile(path)
moved := strings.Count(strings.SplitN(string(content), "target line", 2)[0], "\n") + 1
if moved != 82 {
t.Fatalf("fixture moved the target to line %d, expected 82", moved)
}
if !tracker.SeenRange(path, moved, moved) {
t.Errorf("the read at lines 70-90 was not shifted with the content: line %d reads as unseen", moved)
}
second := runTrackedEdit(t, tool, tracker, path, "target line", "target changed")
if strings.Contains(second.Output, "Error") {
t.Fatalf("editing a line that was read and merely moved was refused: %s", second.Output)
}
}
// THE GUARD STILL GUARDS. Carrying reads across must not credit the model with
// content it never saw — that is the whole reason the check exists.
func TestAnUnreadRegionIsStillRefusedAfterAnEdit(t *testing.T) {
path, tracker, tool := editTrackerFixture(t, numberedLines(300, map[int]string{
50: "seen line",
250: "never read line",
}))
// Only 40-60 is read. Line 250 is not.
tracker.RecordSeenRange(path, 40, 60, 300)
if res := runTrackedEdit(t, tool, tracker, path, "seen line", "seen line edited"); strings.Contains(res.Output, "Error") {
t.Fatalf("setup edit failed: %s", res.Output)
}
res := runTrackedEdit(t, tool, tracker, path, "never read line", "sneaky")
if !strings.Contains(res.Output, "not been read exactly in this session") {
t.Fatalf("an edit into a region that was NEVER read was allowed: %s", res.Output)
}
}
// A RANGE THE EDIT SPANS IS SPLIT, NOT DROPPED — and getting this wrong is not
// hypothetical: dropping on overlap was my first attempt, and it left the
// original defect exactly in place. A read almost always CONTAINS the line it
// is about to edit, because containing it is why it was read.
//
// So the flanks survive and only the rewritten lines stop being known.
func TestARangeTheEditSpansIsSplitAroundIt(t *testing.T) {
path, tracker, tool := editTrackerFixture(t, numberedLines(100, map[int]string{
20: "alpha",
21: "beta",
22: "gamma",
}))
tracker.RecordSeenRange(path, 15, 30, 100)
// Replace a three-line span with one line, entirely inside the read range.
if res := runTrackedEdit(t, tool, tracker, path, "alpha\nbeta\ngamma", "merged"); strings.Contains(res.Output, "Error") {
t.Fatalf("setup edit failed: %s", res.Output)
}
// Before the edit: still known, still at the same line numbers.
if !tracker.SeenRange(path, 15, 19) {
t.Error("lines 15-19 sit before the edit and did not move, but were forgotten")
}
// After it: still known, shifted up by the two lines the edit removed.
if !tracker.SeenRange(path, 21, 28) {
t.Error("lines 23-30 were read and merely moved to 21-28, but were forgotten")
}
// The rewritten span itself is no longer described by that read.
if tracker.SeenRange(path, 15, 30) {
t.Error("the whole original range still reads as seen, so the model is credited with content the edit replaced")
}
}
// AN EXTERNAL CHANGE STILL INVALIDATES EVERYTHING. RecordEdit is only for edits
// we made; a file changed behind our back is exactly the case the blanket drop
// is right for, and it must keep working.
func TestAnExternalChangeStillDropsEveryRead(t *testing.T) {
path, tracker, _ := editTrackerFixture(t, numberedLines(100, map[int]string{50: "hello"}))
tracker.RecordSeenRange(path, 40, 60, 100)
if !tracker.SeenRange(path, 50, 50) {
t.Fatal("setup: line 50 should be seen")
}
// Something outside Zero rewrites the file.
changed := strings.Repeat("different\n", 100)
if err := os.WriteFile(path, []byte(changed), 0o644); err != nil {
t.Fatal(err)
}
info, _ := os.Stat(path)
tracker.Record(path, []byte(changed), info)
if tracker.SeenRange(path, 50, 50) {
t.Error("an external rewrite left the old read ranges in place")
}
}
// A file read in FULL stays read in full: an edit of ours does not make that
// untrue, and re-reading a whole file after every edit is the cost this avoids.
func TestAWhollyReadFileStaysWhollyRead(t *testing.T) {
path, tracker, tool := editTrackerFixture(t, numberedLines(40, map[int]string{5: "one"}))
tracker.RecordSeenRange(path, 1, 40, 40)
if !tracker.SeenWhole(path) {
t.Fatal("setup: the file should read as wholly seen")
}
if res := runTrackedEdit(t, tool, tracker, path, "one", "one\ntwo"); strings.Contains(res.Output, "Error") {
t.Fatalf("edit failed: %s", res.Output)
}
if !tracker.SeenWhole(path) {
t.Error("a wholly-read file stopped being wholly read after an edit")
}
}
// The span helpers, directly: these decide what survives, so their edges are
// worth pinning independently of the tool.
func TestChangedSpanHelpers(t *testing.T) {
t.Run("a single line replaced in place", func(t *testing.T) {
first, last, delta := changedLineSpan("a\nb\nc", "a\nB\nc")
if first != 2 || last != 2 || delta != 0 {
t.Fatalf("got (%d,%d,%d), want (2,2,0)", first, last, delta)
}
})
t.Run("one line becomes three", func(t *testing.T) {
first, last, delta := changedLineSpan("a\nb\nc", "a\nx\ny\nz\nc")
if first != 2 || last != 2 || delta != 2 {
t.Fatalf("got (%d,%d,%d), want (2,2,2)", first, last, delta)
}
})
t.Run("lines removed", func(t *testing.T) {
first, last, delta := changedLineSpan("a\nb\nc\nd", "a\nd")
if first != 2 || last != 3 || delta != -2 {
t.Fatalf("got (%d,%d,%d), want (2,3,-2)", first, last, delta)
}
})
t.Run("bytes", func(t *testing.T) {
first, last, delta := changedByteSpan([]byte("abcdef"), []byte("abXYef"))
if first != 2 || last != 4 || delta != 0 {
t.Fatalf("got (%d,%d,%d), want (2,4,0)", first, last, delta)
}
})
}
// A file read whole IN PIECES must stay seen-whole after an edit. SeenWhole
// derives its answer from the ranges precisely because the raw flag is only set
// by a single covering read; RecordEdit branching on the flag instead sent this
// case down the split path, and the write_file refusal the derived answer exists
// to prevent came back within one edit.
func TestAFileReadWholeInPiecesStaysWholeAfterAnEdit(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "chunked.go")
content := "l1\nl2\nl3\nl4\n"
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
tracker := NewFileTracker()
info, _ := os.Stat(path)
tracker.Record(path, []byte(content), info)
// Two reads that together cover the file, neither covering it alone.
tracker.RecordSeenRange(path, 1, 2, 4)
tracker.RecordSeenRange(path, 3, 4, 4)
if !tracker.SeenWhole(path) {
t.Fatal("two chunked reads did not add up to seen-whole")
}
updated := "l1\nl2X\nl3\nl4\n"
if err := os.WriteFile(path, []byte(updated), 0o600); err != nil {
t.Fatal(err)
}
newInfo, _ := os.Stat(path)
tracker.RecordEdit(path, []byte(content), []byte(updated), newInfo)
if !tracker.SeenWhole(path) {
t.Error("a file read whole in two chunks stopped being seen whole after one edit")
}
}
// observation.total is compared against read ranges, so it has to be the line
// count a READER would give. strings.Split leaves an empty final element for
// content ending in a newline, which counted one line too many — and since
// SeenWhole asks whether the ranges cover 1..total, that made full coverage
// unreachable for a file that had just been read in full.
func TestCountLinesMatchesWhatAReaderWouldSay(t *testing.T) {
for _, testCase := range []struct {
content string
want int
}{
{"a\nb\nc\n", 3}, // the common case: trailing newline opens no line
{"a\nb\nc", 3},
{"\n", 1},
{"", 0},
} {
if got := countLines([]byte(testCase.content)); got != testCase.want {
t.Errorf("countLines(%q) = %d, want %d", testCase.content, got, testCase.want)
}
}
}
+255 -5
View File
@@ -1,11 +1,13 @@
package tools
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"sort"
"strings"
"sync"
"time"
)
@@ -165,6 +167,241 @@ func (tracker *FileTracker) RecordSeenBytes(absPath string, start, end, total in
tracker.seen[absPath] = observation
}
// RecordEdit re-baselines absPath after an edit THIS SESSION made, keeping the
// reads the edit did not disturb.
//
// WHY THIS EXISTS RATHER THAN Record. RecordHash drops every recorded range when
// the content hash moves, which is right for a change we did not make: we cannot
// say which lines still hold what was read. After our own edit we can say
// exactly. The content before the first changed line is byte-identical and sits
// at the same line numbers; the content after the last changed line is
// byte-identical and has moved by a known delta. Only the lines the edit
// actually spans stop describing the file.
//
// Dropping the lot instead cost a real run. A 371-line file was read in three
// pieces (40-45, 85-260, 260-371) and one two-line edit at line 92 erased the
// credit for all three: the next six edits — into regions that had been read,
// that the edit did not touch, in a file whose line count had not changed — were
// each refused as content "not read in this session", and the repeated-failure
// guard halted the run. The error even told the model to re-read, which would
// have been undone by its next successful edit. The guard is right that a model
// must not edit what it has not seen; it was wrong about what it had seen.
func (tracker *FileTracker) RecordEdit(absPath string, before, after []byte, info os.FileInfo) {
if tracker == nil {
return
}
firstLine, lastLineBefore, lineDelta := changedLineSpan(string(before), string(after))
firstByte, lastByteBefore, byteDelta := changedByteSpan(before, after)
tracker.mu.Lock()
defer tracker.mu.Unlock()
version := FileVersion{Hash: HashContent(after)}
if info != nil {
version.Size = info.Size()
version.MTime = info.ModTime()
}
tracker.versions[absPath] = version
observation, tracked := tracker.seen[absPath]
if !tracked {
return
}
// The DERIVED answer, not the raw flag. Branching on observation.whole here
// sent a file read whole in two chunks — seenWhole true, flag false — into
// the split path below, where it stopped being seen whole after one edit.
if seenWhole(observation) {
// Still whole: every line was read, and an edit of ours does not make
// that untrue. Re-baseline as a single covering observation so the
// answer survives regardless of how the ranges shifted.
observation.whole = true
observation.ranges = nil
observation.byteRanges = nil
observation.total = countLines(after)
observation.totalBytes = len(after)
tracker.seen[absPath] = observation
return
}
// SPLIT AROUND THE EDIT, never drop the whole range. A read almost always
// SPANS the line it is about to edit — that is why it was read — so dropping
// on overlap would have thrown away 85-260 to change line 92 and left the
// original defect in place under a longer implementation.
kept := make([]lineRange, 0, len(observation.ranges)+1)
for _, seen := range observation.ranges {
if end := min(seen.end, firstLine-1); seen.start <= end {
kept = append(kept, lineRange{start: seen.start, end: end})
}
if start := max(seen.start, lastLineBefore+1); start <= seen.end {
kept = append(kept, lineRange{start: start + lineDelta, end: seen.end + lineDelta})
}
}
observation.ranges = kept
if observation.total != 0 {
observation.total = countLines(after)
}
// Same split, on half-open byte intervals.
keptBytes := make([]lineRange, 0, len(observation.byteRanges)+1)
for _, seen := range observation.byteRanges {
if end := min(seen.end, firstByte); seen.start < end {
keptBytes = append(keptBytes, lineRange{start: seen.start, end: end})
}
if start := max(seen.start, lastByteBefore); start < seen.end {
keptBytes = append(keptBytes, lineRange{start: start + byteDelta, end: seen.end + byteDelta})
}
}
observation.byteRanges = keptBytes
if observation.totalBytes != 0 {
observation.totalBytes = len(after)
}
tracker.seen[absPath] = observation
}
// changedLineSpan reports the 1-based first line that differs between before and
// after, the 1-based last line of BEFORE that differs, and the line-count delta.
//
// Computed from a common prefix and suffix rather than from the caller's
// replacement spans: one edit_file call with replace_all can rewrite many
// scattered occurrences, and the span between the outermost two is the only
// region that is honestly unknown afterwards.
// The span is derived by scanning BYTES, not by materialising lines.
//
// The first version split both versions into []string. That allocates one string
// header per line for each version — work that scales with the SIZE OF THE FILE
// rather than the size of the edit, and it ran before RecordEdit had even checked
// whether there was a tracked observation to update. @jatmn measured 32,036,640
// bytes for two 2 MB versions of a million short lines, and extrapolated roughly
// 1.6 GB of headers for a 100 MB file before counting the content and hashes
// already live alongside them.
//
// That mattered beyond memory pressure: edit_file writes the updated bytes BEFORE
// calling RecordEdit, so an out-of-memory kill lands after the user's file has
// changed and before the tracker baseline catches up — the file and the record of
// it disagree, and nothing says so.
//
// Bytes give the same answer in bounded memory. Two identical byte prefixes share
// every line that ends inside them, so counting newlines in the common prefix
// counts the unchanged leading lines directly.
func changedLineSpan(before, after string) (firstChanged, lastChangedBefore, delta int) {
beforeCount := countTrackedLines(before)
afterCount := countTrackedLines(after)
prefix := commonLinePrefix(before, after)
// Never more leading lines than the shorter version has. The byte scan can
// otherwise claim a line that only one side possesses — two empty versions
// share a whole "line" that neither actually contains.
prefix = min(prefix, min(beforeCount, afterCount))
// The suffix may not reach back past the lines the prefix already claimed,
// exactly as the line-array loop bounded itself.
suffix := commonLineSuffix(before, after, min(beforeCount-prefix, afterCount-prefix))
return prefix + 1, beforeCount - suffix, afterCount - beforeCount
}
// commonLinePrefix counts the leading lines that are byte-identical in both.
func commonLinePrefix(before, after string) int {
limit := min(len(before), len(after))
at := 0
for at < limit && before[at] == after[at] {
at++
}
lines := strings.Count(before[:at], "\n")
// A LINE THAT ENDS EXACTLY WHERE THE SCAN STOPPED IS STILL SHARED. Scanning
// halts where the bytes diverge OR where the shorter version runs out, and in
// the second case the line containing that point can be complete and equal on
// both sides — "a\nb" against "a\nb\nc" diverges only because the first ended,
// and "b" is a whole matching line in each.
if lineBoundary(before, at) && lineBoundary(after, at) {
lines++
}
return lines
}
// commonLineSuffix counts the trailing lines that are byte-identical in both, up
// to the ceiling the prefix leaves.
func commonLineSuffix(before, after string, ceiling int) int {
if ceiling <= 0 {
return 0
}
limit := min(len(before), len(after))
back := 0
for back < limit && before[len(before)-1-back] == after[len(after)-1-back] {
back++
}
beforeAt, afterAt := len(before)-back, len(after)-back
// Each newline inside the shared tail closes a line that lies wholly within
// it. The line the tail STARTS in is shared only when the tail begins on a
// line boundary in both versions — otherwise its opening bytes differ and it
// is a changed line that merely ends the same way.
lines := strings.Count(before[beforeAt:], "\n")
if lineStart(before, beforeAt) && lineStart(after, afterAt) {
lines++
}
return min(lines, ceiling)
}
// lineBoundary reports whether at is the end of a line: the end of the text, or
// the position of the newline that closes it.
func lineBoundary(text string, at int) bool { return at == len(text) || text[at] == '\n' }
// lineStart reports whether at begins a line.
func lineStart(text string, at int) bool { return at == 0 || text[at-1] == '\n' }
// changedByteSpan is changedLineSpan in bytes: the first differing offset, the
// end offset of the changed region in BEFORE, and the size delta.
func changedByteSpan(before, after []byte) (firstChanged, lastChangedBefore, delta int) {
prefix := 0
for prefix < len(before) && prefix < len(after) && before[prefix] == after[prefix] {
prefix++
}
suffix := 0
for suffix < len(before)-prefix && suffix < len(after)-prefix &&
before[len(before)-1-suffix] == after[len(after)-1-suffix] {
suffix++
}
return prefix, len(before) - suffix, len(after) - len(before)
}
// countLines reports the line count a READER would give the content, which is
// what observation.total is compared against.
//
// The trailing newline does not open a line. strings.Split leaves an empty final
// element for "a\nb\nc\n" and so counted 4 where a read reports 3 — and because
// SeenWhole asks whether the ranges cover 1..total, an inflated total made full
// coverage unreachable for the very file that had just been read in full. Almost
// every text file ends in a newline, so this was the common case rather than an
// edge one.
//
// countTrackedLines keeps the OTHER rule deliberately: the span arithmetic has to
// match the indices strings.Split produced, so it counts the empty element after
// a trailing newline where this one does not. Two rules, two names, both stated —
// they were one function with an if, and that is how they were confused.
func countLines(content []byte) int {
// Counted, not split. This only ever needed a number, and building a slice of
// every line to take its length was the third full per-line allocation in the
// same call path.
if len(content) == 0 {
return 0
}
lines := bytes.Count(content, []byte{'\n'})
if content[len(content)-1] != '\n' {
lines++
}
return lines
}
// countTrackedLines is countLines' rule for the string form: the number of
// elements strings.Split would produce, which counts the empty element after a
// trailing newline. The two differ deliberately — observation.total compares
// against what a READER reports, while the span arithmetic has to match the
// indices the split produced.
func countTrackedLines(text string) int {
if text == "" {
return 0
}
return strings.Count(text, "\n") + 1
}
// coversFully reports whether ranges together cover every line in [start, end].
//
// Ranges are merged rather than scanned line by line: a caller asking about a
@@ -274,14 +511,27 @@ func (tracker *FileTracker) SeenWhole(absPath string) bool {
if !ok {
return false
}
return seenWhole(observation)
}
// seenWhole is the DERIVED answer to "has every line been seen", and the single
// place that decides it. Callers must hold the lock.
//
// Derived from the ranges rather than read off the flag, because the flag is
// only ever set by a SINGLE read covering the file: a file read in two halves
// stayed "not seen whole" forever even though SeenRange agreed every line had
// been seen, and write_file, which gates on this, refused the overwrite with
// advice to read the file that could not change the answer.
//
// IT HAS TO BE ONE FUNCTION. RecordEdit branched on the raw flag while this
// derived the answer, so the very case the derivation exists to rescue fell into
// the wrong arm and lost: a file read whole in two chunks reported SeenWhole
// true, then reported false after a single edit, putting the write_file refusal
// back within one edit of where it was fixed.
func seenWhole(observation fileObservation) bool {
if observation.whole {
return true
}
// Derived from the ranges rather than tracked as its own flag. The flag was
// only ever set by a SINGLE read covering the file, so a file read in two
// halves stayed "not seen whole" forever even though SeenRange agreed every
// line had been seen — and write_file, which gates on this, then refused the
// overwrite with advice to read the file that could not change the answer.
if observation.total > 0 && coversFully(observation.ranges, 1, observation.total) {
return true
}
+83
View File
@@ -3,6 +3,7 @@ package tools
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -153,3 +154,85 @@ func TestNilFileTrackerCreatedFilesIsANoop(t *testing.T) {
t.Fatalf("CreatedFiles() on nil tracker = %v, want nil", got)
}
}
// THE SPAN IS DERIVED IN BOUNDED MEMORY, whatever the file size.
//
// changedLineSpan used to split both versions into []string, so it allocated one
// string header per line for each — work scaling with the SIZE OF THE FILE rather
// than the size of the edit, and it ran before RecordEdit had even checked whether
// there was an observation to update. @jatmn measured 32,036,640 bytes for two
// 2 MB versions of a million short lines.
//
// The cost was not only memory. edit_file writes the updated bytes BEFORE calling
// RecordEdit, so an out-of-memory kill lands after the user's file has changed and
// before the tracker baseline catches up — file and record disagree, silently.
//
// This asserts an absolute ceiling rather than a ratio, so a future rewrite that
// reintroduces per-line slices fails here rather than merely getting slower.
func TestTheChangedSpanDoesNotAllocatePerLine(t *testing.T) {
before := strings.Repeat("x\n", 200_000)
after := before[:len(before)-2] + "y\n"
spanBytes := testing.Benchmark(func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
changedLineSpan(before, after)
}
}).AllocedBytesPerOp()
if spanBytes > 4096 {
t.Errorf("changedLineSpan allocated %d bytes for a %d-line pair; per-line slices are back", spanBytes, 200_000)
}
content := []byte(before)
countBytes := testing.Benchmark(func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
countLines(content)
}
}).AllocedBytesPerOp()
if countBytes > 4096 {
t.Errorf("countLines allocated %d bytes to return a number", countBytes)
}
}
// AND IT GIVES THE SAME ANSWERS THE LINE-ARRAY VERSION DID. The rewrite is only
// safe if it is behaviour-preserving, so the replaced implementation is kept here
// as an oracle and both are asked the same questions. This caught 206 mismatches
// in the first attempt at the byte scan — empty versions and partially shared
// trailing lines, neither of which the hand-written cases below would have found.
func TestTheByteScanAgreesWithTheLineSplit(t *testing.T) {
oracle := func(before, after string) (int, int, int) {
split := func(text string) []string {
if text == "" {
return nil
}
return strings.Split(text, "\n")
}
beforeLines, afterLines := split(before), split(after)
prefix := 0
for prefix < len(beforeLines) && prefix < len(afterLines) && beforeLines[prefix] == afterLines[prefix] {
prefix++
}
suffix := 0
for suffix < len(beforeLines)-prefix && suffix < len(afterLines)-prefix &&
beforeLines[len(beforeLines)-1-suffix] == afterLines[len(afterLines)-1-suffix] {
suffix++
}
return prefix + 1, len(beforeLines) - suffix, len(afterLines) - len(beforeLines)
}
versions := []string{
"", "a", "\n", "a\n", "a\nb", "a\nb\n", "a\nb\nc", "a\nb\nc\n",
"\n\n", "x\n\ny", "a\nb\nc\nd\ne", "same\nsame\nsame\n", "ab\nc", "b\nc",
}
for _, before := range versions {
for _, after := range versions {
wantFirst, wantLast, wantDelta := oracle(before, after)
gotFirst, gotLast, gotDelta := changedLineSpan(before, after)
if wantFirst != gotFirst || wantLast != gotLast || wantDelta != gotDelta {
t.Errorf("changedLineSpan(%q, %q) = (%d, %d, %d), want (%d, %d, %d)",
before, after, gotFirst, gotLast, gotDelta, wantFirst, wantLast, wantDelta)
}
}
}
}
+1 -1
View File
@@ -109,7 +109,7 @@ var commandDefinitions = []commandDefinition{
name: "/voice",
usage: "/voice",
group: commandGroupRuntime,
description: "Toggle voice mode (hold Space to dictate).",
description: "Toggle voice mode (" + voiceCaptureUsage + ").",
kind: commandVoice,
},
{
+9 -14
View File
@@ -88,7 +88,7 @@ type dictationController struct {
waveBars []int
waveTick int
// voiceModeEnabled repurposes Space into hold-to-record (§13.9).
// voiceModeEnabled repurposes Ctrl+Space into hold-to-record (§13.9).
voiceModeEnabled bool
// eventTypesSupported records whether the terminal confirmed key-release
// reporting (Kitty protocol); eventTypesKnown gates it until the terminal has
@@ -173,7 +173,7 @@ func firstNonEmptyStr(a, b string) string {
func (d dictationController) active() bool { return d.phase != dictIdle }
// toggleDictation starts a recording when idle and stops-and-transcribes when
// recording. Invoked by the voice-mode Space gesture; a call during
// recording. Invoked by the voice-mode Ctrl+Space gesture; a call during
// startup/transcription is ignored (the machine is mid-transition).
func (m model) toggleDictation() (model, tea.Cmd) {
if !m.dictation.available() {
@@ -193,15 +193,15 @@ func (m model) toggleDictation() (model, tea.Cmd) {
}
}
// toggleVoiceMode flips the /voice hold-to-record gesture — the dictation
// trigger. While on, Space records; run /voice again to type spaces normally.
// toggleVoiceMode flips the /voice hold-to-record gesture. While on,
// Ctrl+Space records and ordinary Space continues to type normally.
func (m model) toggleVoiceMode() (model, tea.Cmd) {
if !m.dictation.available() {
return m.appendSystemNotice("Dictation is not configured. See docs/dictation.md to set up a local engine or a Groq/OpenAI key."), nil
}
m.dictation.voiceModeEnabled = !m.dictation.voiceModeEnabled
if m.dictation.voiceModeEnabled {
return m.showTransientNoticeInline("Voice mode on — hold Space to dictate; run /voice again to turn it off.", transientNoticeSuccess), nil
return m.showTransientNoticeInline("Voice mode on — "+voiceCaptureUsage+". Run /voice again to turn it off.", transientNoticeSuccess), nil
}
// Turning voice off is the "done dictating" signal, so release the warm sherpa
// streaming server — otherwise a loaded model keeps idling in RAM (and holding
@@ -374,7 +374,7 @@ func (m model) handleDictationStarted(msg dictationStartedMsg) (model, tea.Cmd)
if m.dictation.phase == dictStarting {
m.dictation.phase = dictRecording
}
// A voice-mode Space release that arrived mid-startup asked us to stop as soon
// A voice-mode Ctrl+Space release that arrived mid-startup asked us to stop as soon
// as recording began.
if m.dictation.voiceStopPending && m.dictation.phase == dictRecording {
m.dictation.voiceStopPending = false
@@ -480,12 +480,8 @@ func needsLeadingSpace(state composerState) bool {
func (m model) dictationStatusChip() string {
switch m.dictation.phase {
case dictStarting, dictRecording:
stop := "release Space to stop"
if !m.dictation.eventTypesSupported {
stop = "press Space to stop" // press-to-toggle fallback
}
wave := zeroTheme.amber.Render("● " + renderWaveBars(m.dictation.waveBars) + " REC")
return wave + zeroTheme.muted.Render(" · "+stop+", Esc to cancel")
return wave + zeroTheme.muted.Render(" · "+m.voiceCaptureStatus()+", Esc to cancel")
case dictTranscribing:
return zeroTheme.accent.Render("●") + " " + zeroTheme.muted.Render("transcribing…")
}
@@ -601,13 +597,12 @@ func (m model) handleRecTick() (model, tea.Cmd) {
return m, nil
}
// voiceModeIndicator renders the persistent "voice mode on" hint shown while
// idle so the user knows Space is repurposed to hold-to-record (§10).
// voiceModeIndicator renders the current voice gesture or transcription state.
func (m model) voiceModeIndicator() string {
if !m.dictation.voiceModeEnabled {
return ""
}
return zeroTheme.accent.Render("🎙 voice") + zeroTheme.muted.Render(" · "+m.dictation.currentModelLabel())
return zeroTheme.accent.Render("🎙 voice") + zeroTheme.muted.Render(" · "+m.voiceCaptureStatus()+" · "+m.dictation.currentModelLabel())
}
// dictationErrorText renders a dictation error for the transcript: a missing-
+41 -10
View File
@@ -4,17 +4,19 @@ import (
tea "charm.land/bubbletea/v2"
)
// Voice mode's Space-hold gesture — the (only) dictation trigger. Two terminal
const voiceCaptureUsage = "hold Ctrl+Space to record; press it to toggle where key releases are unavailable"
// Voice mode's Ctrl+Space gesture — the (only) dictation trigger. Two terminal
// tiers:
//
// 1. The terminal confirms key-release reporting (Ghostty/Kitty/WezTerm, …):
// Space press starts recording, Space release stops it — true hold-to-record,
// no ambiguity.
// 2. The terminal does not confirm release events: Space falls back to
// Ctrl+Space press starts recording and release stops it — true
// hold-to-record, with ordinary Space left available for typing.
// 2. The terminal does not confirm release events: Ctrl+Space falls back to
// press-to-toggle (press to start, press again to stop) — a deliberately
// simpler, robust fallback than inferring release from key-repeat timing
// (racy in a terminal). This works on ANY terminal, so voice mode is always
// usable once enabled.
// (racy in a terminal). Legacy NUL input is decoded as Ctrl+Space by the
// terminal input layer, so the same matcher covers both tiers.
//
// Only active while voice mode is on; the rest of dispatch is built on
// KeyPressMsg and is untouched.
@@ -27,8 +29,37 @@ func (m model) handleKeyboardEnhancements(msg tea.KeyboardEnhancementsMsg) model
return m
}
// handleVoiceSpacePress handles a Space press while voice mode is on.
func (m model) handleVoiceSpacePress(msg tea.KeyMsg) (model, tea.Cmd) {
func voiceCaptureKey(msg tea.KeyMsg) bool {
mod := msg.Key().Mod &^ (tea.ModCapsLock | tea.ModNumLock)
return keyIs(msg, tea.KeySpace) && mod == tea.ModCtrl
}
// voiceCaptureReleaseKey matches either half of the held Ctrl+Space chord. A
// terminal may report Ctrl releasing first, or report the later Space release
// without ModCtrl because Ctrl is no longer down.
func voiceCaptureReleaseKey(msg tea.KeyMsg) bool {
return keyIs(msg, tea.KeySpace) || keyIs(msg, tea.KeyLeftCtrl) || keyIs(msg, tea.KeyRightCtrl)
}
func (m model) voiceCaptureStatus() string {
switch m.dictation.phase {
case dictStarting, dictRecording:
if m.dictation.eventTypesSupported {
return "release Ctrl+Space to stop"
}
return "press Ctrl+Space to stop"
case dictTranscribing:
return "transcribing…"
default:
if m.dictation.eventTypesSupported {
return "hold Ctrl+Space to record"
}
return "press Ctrl+Space to record"
}
}
// handleVoiceCapturePress handles Ctrl+Space while voice mode is on.
func (m model) handleVoiceCapturePress(msg tea.KeyMsg) (model, tea.Cmd) {
if !m.dictation.eventTypesSupported {
// Tier 2: no release events — press-to-toggle.
return m.toggleDictation()
@@ -45,8 +76,8 @@ func (m model) handleVoiceSpacePress(msg tea.KeyMsg) (model, tea.Cmd) {
return m, nil // already recording; the release will stop it
}
// handleVoiceSpaceRelease stops a hold-to-record session when Space is released.
func (m model) handleVoiceSpaceRelease() (model, tea.Cmd) {
// handleVoiceCaptureRelease stops a hold-to-record session when Ctrl+Space is released.
func (m model) handleVoiceCaptureRelease() (model, tea.Cmd) {
if !m.dictation.spaceHeld {
return m, nil
}
+200 -15
View File
@@ -2,6 +2,7 @@ package tui
import (
"context"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
@@ -36,7 +37,7 @@ func TestToggleVoiceModeFlips(t *testing.T) {
if !next.dictation.voiceModeEnabled {
t.Fatal("first /voice should enable voice mode")
}
if next.transientNotice.text != "Voice mode on — hold Space to dictate; run /voice again to turn it off." {
if next.transientNotice.text != "Voice mode on — hold Ctrl+Space to record; press it to toggle where key releases are unavailable. Run /voice again to turn it off." {
t.Errorf("voice-mode-on notice = %q", next.transientNotice.text)
}
if transcriptHasText(next, "Voice mode on") {
@@ -68,15 +69,27 @@ func TestKeyboardEnhancementsRecorded(t *testing.T) {
}
}
func TestVoiceSpaceHoldStartsRecording(t *testing.T) {
func TestVoiceCaptureKeyIgnoresLockModifiers(t *testing.T) {
for _, mod := range []tea.KeyMod{
tea.ModCtrl | tea.ModCapsLock,
tea.ModCtrl | tea.ModNumLock,
tea.ModCtrl | tea.ModCapsLock | tea.ModNumLock,
} {
if !voiceCaptureKey(tea.KeyPressMsg(tea.Key{Code: tea.KeySpace, Mod: mod})) {
t.Fatalf("Ctrl+Space with lock modifiers %v was not recognized", mod)
}
}
}
func TestVoiceCaptureHoldStartsRecording(t *testing.T) {
m := model{dictation: batchOnlyController()}
m.dictation.voiceModeEnabled = true
m.dictation.eventTypesSupported = true
press := tea.KeyPressMsg(tea.Key{Code: tea.KeySpace})
next, cmd := m.handleVoiceSpacePress(press)
press := tea.KeyPressMsg(tea.Key{Code: tea.KeySpace, Mod: tea.ModCtrl})
next, cmd := m.handleVoiceCapturePress(press)
if next.dictation.phase != dictStarting {
t.Fatalf("Space press should start recording (phase=%d)", next.dictation.phase)
t.Fatalf("Ctrl+Space press should start recording (phase=%d)", next.dictation.phase)
}
if !next.dictation.spaceHeld {
t.Error("spaceHeld should be set in hold mode")
@@ -86,28 +99,28 @@ func TestVoiceSpaceHoldStartsRecording(t *testing.T) {
}
}
func TestVoiceSpaceHoldIgnoresRepeat(t *testing.T) {
func TestVoiceCaptureHoldIgnoresRepeat(t *testing.T) {
m := model{dictation: batchOnlyController()}
m.dictation.voiceModeEnabled = true
m.dictation.eventTypesSupported = true
m.dictation.phase = dictRecording
m.dictation.spaceHeld = true
repeat := tea.KeyPressMsg(tea.Key{Code: tea.KeySpace, IsRepeat: true})
next, _ := m.handleVoiceSpacePress(repeat)
repeat := tea.KeyPressMsg(tea.Key{Code: tea.KeySpace, Mod: tea.ModCtrl, IsRepeat: true})
next, _ := m.handleVoiceCapturePress(repeat)
if next.dictation.phase != dictRecording {
t.Error("auto-repeat while held must not restart or change phase")
}
}
func TestVoiceSpaceReleaseStops(t *testing.T) {
func TestVoiceCaptureReleaseStops(t *testing.T) {
m := model{dictation: batchOnlyController()}
m.dictation.voiceModeEnabled = true
m.dictation.eventTypesSupported = true
m.dictation.phase = dictRecording
m.dictation.spaceHeld = true
next, _ := m.handleVoiceSpaceRelease()
next, _ := m.handleVoiceCaptureRelease()
if next.dictation.phase != dictTranscribing {
t.Errorf("release should stop recording → transcribing (phase=%d)", next.dictation.phase)
}
@@ -123,7 +136,7 @@ func TestVoiceReleaseDuringStartupDefersStop(t *testing.T) {
m.dictation.phase = dictStarting
m.dictation.spaceHeld = true
next, _ := m.handleVoiceSpaceRelease()
next, _ := m.handleVoiceCaptureRelease()
if !next.dictation.voiceStopPending {
t.Error("a release during startup should defer the stop")
}
@@ -134,14 +147,186 @@ func TestVoiceReleaseDuringStartupDefersStop(t *testing.T) {
}
}
func TestVoiceSpaceToggleFallback(t *testing.T) {
func TestVoiceCaptureToggleFallback(t *testing.T) {
m := model{dictation: batchOnlyController()}
m.dictation.voiceModeEnabled = true
m.dictation.eventTypesSupported = false // no release events → toggle fallback
press := tea.KeyPressMsg(tea.Key{Code: tea.KeySpace})
next, _ := m.handleVoiceSpacePress(press)
press := tea.KeyPressMsg(tea.Key{Code: tea.KeySpace, Mod: tea.ModCtrl})
next, _ := m.handleVoiceCapturePress(press)
if next.dictation.phase != dictStarting {
t.Errorf("toggle-mode Space should start recording (phase=%d)", next.dictation.phase)
t.Errorf("toggle-mode Ctrl+Space should start recording (phase=%d)", next.dictation.phase)
}
}
func TestVoiceModeCtrlSpaceReleaseStopsHoldRecording(t *testing.T) {
m := newModel(t.Context(), Options{})
m.dictation = batchOnlyController()
m.dictation.voiceModeEnabled = true
m.dictation.eventTypesSupported = true
m.dictation.phase = dictRecording
m.dictation.spaceHeld = true
updated, _ := m.Update(tea.KeyReleaseMsg(tea.Key{Code: tea.KeySpace, Mod: tea.ModCtrl}))
next := updated.(model)
if next.dictation.phase != dictTranscribing || next.dictation.spaceHeld {
t.Fatalf("Ctrl+Space release did not stop hold recording: phase=%d held=%v", next.dictation.phase, next.dictation.spaceHeld)
}
}
func TestVoiceModeCaptureStopsWhenEitherChordKeyIsReleased(t *testing.T) {
for _, test := range []struct {
name string
key tea.Key
}{
{name: "left Ctrl first", key: tea.Key{Code: tea.KeyLeftCtrl}},
{name: "right Ctrl first", key: tea.Key{Code: tea.KeyRightCtrl}},
{name: "Space after Ctrl", key: tea.Key{Code: tea.KeySpace}},
} {
t.Run(test.name, func(t *testing.T) {
m := newModel(t.Context(), Options{})
m.dictation = batchOnlyController()
m.dictation.voiceModeEnabled = true
m.dictation.eventTypesSupported = true
m.dictation.phase = dictRecording
m.dictation.spaceHeld = true
updated, _ := m.Update(tea.KeyReleaseMsg(test.key))
next := updated.(model)
if next.dictation.phase != dictTranscribing || next.dictation.spaceHeld {
t.Fatalf("release did not stop hold recording: phase=%d held=%v", next.dictation.phase, next.dictation.spaceHeld)
}
})
}
}
func TestVoiceModeIndicatorDescribesTierAndPhase(t *testing.T) {
for _, test := range []struct {
name string
releaseEvents bool
phase dictationPhase
spaceHeld bool
want string
}{
{name: "toggle idle", want: "press Ctrl+Space to record"},
{name: "hold idle", releaseEvents: true, want: "hold Ctrl+Space to record"},
{name: "toggle recording", phase: dictRecording, want: "press Ctrl+Space to stop"},
{name: "hold recording", releaseEvents: true, phase: dictRecording, spaceHeld: true, want: "release Ctrl+Space to stop"},
{name: "transcribing", phase: dictTranscribing, want: "transcribing"},
} {
t.Run(test.name, func(t *testing.T) {
m := model{dictation: batchOnlyController()}
m.dictation.voiceModeEnabled = true
m.dictation.eventTypesSupported = test.releaseEvents
m.dictation.phase = test.phase
m.dictation.spaceHeld = test.spaceHeld
if got := m.voiceModeIndicator(); !strings.Contains(strings.ToLower(got), strings.ToLower(test.want)) {
t.Fatalf("voice indicator = %q, want %q", got, test.want)
}
})
}
}
func TestDictationStatusChipUsesVoiceCaptureShortcut(t *testing.T) {
for _, test := range []struct {
name string
releaseEvents bool
want string
}{
{name: "toggle", want: "press Ctrl+Space to stop"},
{name: "hold", releaseEvents: true, want: "release Ctrl+Space to stop"},
} {
t.Run(test.name, func(t *testing.T) {
m := model{dictation: batchOnlyController()}
m.dictation.voiceModeEnabled = true
m.dictation.eventTypesSupported = test.releaseEvents
m.dictation.phase = dictRecording
if got := m.dictationStatusChip(); !strings.Contains(got, test.want) {
t.Fatalf("dictation status = %q, want %q", got, test.want)
}
})
}
}
func TestVoiceCommandDescriptionExplainsBothTerminalTiers(t *testing.T) {
for _, command := range commandDefinitions {
if command.kind == commandVoice {
for _, want := range []string{"hold Ctrl+Space", "press it to toggle", "key releases"} {
if !strings.Contains(command.description, want) {
t.Fatalf("voice command description = %q, want %q", command.description, want)
}
}
return
}
}
t.Fatal("voice command definition not found")
}
func TestVoiceModePlainSpaceTypesNormally(t *testing.T) {
for _, releaseEvents := range []bool{false, true} {
t.Run(map[bool]string{false: "toggle", true: "hold"}[releaseEvents], func(t *testing.T) {
m := newModel(t.Context(), Options{})
m.dictation = batchOnlyController()
m.dictation.voiceModeEnabled = true
m.dictation.eventTypesSupported = releaseEvents
updated, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeySpace}))
next := updated.(model)
if got := next.composerValue(); got != " " {
t.Fatalf("plain Space composer = %q, want one space", got)
}
if next.dictation.phase != dictIdle || cmd != nil {
t.Fatalf("plain Space started dictation: phase=%d cmd=%v", next.dictation.phase, cmd != nil)
}
})
}
}
func TestVoiceModeCtrlSpaceStartsAcrossTerminalTiers(t *testing.T) {
for _, releaseEvents := range []bool{false, true} {
t.Run(map[bool]string{false: "toggle", true: "hold"}[releaseEvents], func(t *testing.T) {
m := newModel(t.Context(), Options{})
m.dictation = batchOnlyController()
m.dictation.voiceModeEnabled = true
m.dictation.eventTypesSupported = releaseEvents
updated, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeySpace, Mod: tea.ModCtrl}))
next := updated.(model)
if next.dictation.phase != dictStarting || cmd == nil {
t.Fatalf("Ctrl+Space did not start dictation: phase=%d cmd=%v", next.dictation.phase, cmd != nil)
}
if got := next.composerValue(); got != "" {
t.Fatalf("Ctrl+Space typed into composer: %q", got)
}
})
}
}
func TestVoiceModeTypingPreservesSpacesAcrossTerminalTiers(t *testing.T) {
for _, releaseEvents := range []bool{false, true} {
t.Run(map[bool]string{false: "toggle", true: "hold"}[releaseEvents], func(t *testing.T) {
m := newModel(t.Context(), Options{})
m.dictation = batchOnlyController()
m.dictation.voiceModeEnabled = true
m.dictation.eventTypesSupported = releaseEvents
for _, key := range []tea.Key{
{Code: 'f', Text: "f"},
{Code: 'i', Text: "i"},
{Code: 'x', Text: "x"},
{Code: tea.KeySpace},
{Code: 't', Text: "t"},
{Code: 'h', Text: "h"},
{Code: 'e', Text: "e"},
} {
updated, _ := m.Update(tea.KeyPressMsg(key))
m = updated.(model)
}
if got := m.composerValue(); got != "fix the" {
t.Fatalf("composer = %q, want %q", got, "fix the")
}
if m.dictation.phase != dictIdle {
t.Fatalf("typing started dictation: phase=%d", m.dictation.phase)
}
})
}
}
+23 -9
View File
@@ -30,6 +30,7 @@ import (
"github.com/Gitlawb/zero/internal/peermsg"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
@@ -547,6 +548,7 @@ type model struct {
recapIdleArmed bool
recapIdleRunID int
idleRecap string
compactionModel string // preferences.compactionModel (see providers.CompactionModelID)
modelPickerLoading bool
modelPickerLoadingProviderID string
modelPickerLoadError string
@@ -978,6 +980,7 @@ func newModel(ctx context.Context, options Options) model {
providerProfile: options.ProviderProfile,
favoriteModels: favoriteModelSet(options.FavoriteModels),
recentModels: normalizeRecentModelEntries(options.RecentModels),
compactionModel: options.CompactionModel,
recapsEnabled: options.RecapsEnabled,
provider: options.Provider,
newProvider: options.NewProvider,
@@ -1536,10 +1539,10 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyboardEnhancementsMsg:
return m.handleKeyboardEnhancements(msg), nil
case tea.KeyReleaseMsg:
// Voice mode's hold-to-record ends on Space release; every other release
// event is ignored (dispatch elsewhere is press-based).
if m.dictation.voiceModeEnabled && keyIs(msg, tea.KeySpace) {
return m.handleVoiceSpaceRelease()
// Voice mode's hold-to-record ends on Ctrl+Space release; every other
// release event is ignored (dispatch elsewhere is press-based).
if m.dictation.voiceModeEnabled && voiceCaptureReleaseKey(msg) {
return m.handleVoiceCaptureRelease()
}
return m, nil
case tea.KeyPressMsg:
@@ -1673,11 +1676,10 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.appendSystemNotice(fmt.Sprintf("Mouse released — drag to select and copy text. Press %s again to re-enable mouse interaction (clicks, right-click paste).", mouseKey)), nil
}
return m.showTransientNoticeInline("Mouse interaction re-enabled.", transientNoticeSuccess), nil
case m.dictation.voiceModeEnabled && !m.transcriptDetailed && keyIs(msg, tea.KeySpace) && !keyHasMod(msg, tea.ModCtrl) && !keyAlt(msg) && m.noBlockingModal():
// Voice mode (/voice) repurposes Space into the record gesture — the only
// dictation trigger — so it must not also type a space. Turn voice mode
// off (/voice) to type normally.
return m.handleVoiceSpacePress(msg)
case m.dictation.voiceModeEnabled && !m.transcriptDetailed && voiceCaptureKey(msg) && m.noBlockingModal():
// Voice mode reserves Ctrl+Space for recording; ordinary Space continues
// through the composer path below.
return m.handleVoiceCapturePress(msg)
case keyIs(msg, tea.KeyEsc):
// Esc is heavily overloaded below (subchat exit, MCP cancel, ask-user,
// permission deny, wizard/picker/suggestions dismiss, ...) before ever
@@ -5482,6 +5484,18 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
// compaction (proactive + reactive) is enabled for every model, not just
// catalogued ones.
options.ContextWindow = modelregistry.AgentContextWindow(m.modelContextWindow(m.modelName))
// Route compaction summarization to a cheap model when one applies
// (env > config > curated default). The factory is lazy, re-evaluated
// against the model in force when the compactor asks (so an escalation
// away from the cheap model gains a summarizer), and the agent falls
// back to the main provider on any summarizer failure. Resolved against
// the ACTIVE profile so it tracks mid-session model switches.
options.Summarizer = providers.CompactionSummarizerFactory(m.providerProfile, m.compactionModel, m.newProvider)
// Let compaction re-derive its threshold after a mid-run escalate_model
// switch instead of keeping the original model's window.
options.ContextWindowFor = func(modelID string) int {
return modelregistry.AgentContextWindow(m.modelContextWindow(modelID))
}
// Post-edit self-correction is on by default in the TUI but kept FAST: it
// runs LSP diagnostics over the changed files only — cheap, change-scoped,
+16 -12
View File
@@ -22,18 +22,22 @@ import (
// Options configures the reusable Zero terminal UI shell.
type Options struct {
Cwd string
Version string // CLI build version, shown on the home screen; empty hides it
UserConfigPath string
DoctorUserConfigPath string
ProjectConfigPath string
ProviderName string
ModelName string
ProviderProfile config.ProviderProfile
SavedProviders []config.ProviderProfile // all configured providers, for the /model multi-provider list
FavoriteModels []string
RecentModels []config.RecentModelEntry
RecapsEnabled bool
Cwd string
Version string // CLI build version, shown on the home screen; empty hides it
UserConfigPath string
DoctorUserConfigPath string
ProjectConfigPath string
ProviderName string
ModelName string
ProviderProfile config.ProviderProfile
SavedProviders []config.ProviderProfile // all configured providers, for the /model multi-provider list
FavoriteModels []string
RecentModels []config.RecentModelEntry
RecapsEnabled bool
// CompactionModel is the resolved preferences.compactionModel value; see
// providers.CompactionModelID for how it combines with the env override
// and the curated cheap defaults.
CompactionModel string
Provider zeroruntime.Provider
NewProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
NewTurnSessionProvider func(config.ProviderProfile, zeroruntime.Provider) zeroruntime.TurnSessionProvider
+1 -1
View File
@@ -123,7 +123,7 @@ func (m model) finalizeSTTModelFromKey(p *sttKeyPromptState, prefix string) (tea
if modelID != "" {
label += " " + modelID
}
return m.showTransientNoticeInline(prefix+"Dictation model: "+label+". Run /voice, then hold Space to dictate.", transientNoticeSuccess), nil
return m.showTransientNoticeInline(prefix+"Dictation model: "+label+". Run /voice; "+voiceCaptureUsage+".", transientNoticeSuccess), nil
}
// sttKeyPromptOverlay renders the API-key prompt as a centered modal, matching
+1 -1
View File
@@ -226,7 +226,7 @@ func (m model) handleSTTModelSelection(value string) (model, string) {
// instead); this is the manual-setup path (e.g. Termux).
hint += " Set stt.localModelPath to a model directory (see docs/dictation.md)."
} else {
hint += " Run /voice, then hold Space to dictate."
hint += " Run /voice; " + voiceCaptureUsage + "."
}
return m, hint
}