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