Skip to content

Commit 05afaf9

Browse files
authored
Merge branch 'main' into fix/5462-chat-store-contracts
2 parents 3396f4d + fbe4ac3 commit 05afaf9

5 files changed

Lines changed: 68 additions & 24 deletions

File tree

‎docs/reference/configuration-backup.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ summary or dropping unknown fields.
9191
The HTTP restore path uses the existing 64 MiB local-snapshot budget, without
9292
changing the 64 kB ordinary request budget. Oversize or invalid envelopes reject
9393
without partial recovery; CLI/effect transport keeps its existing bounds.
94-
Restored files use owner-only permissions. POSIX checkpoint tests and the
95-
packaged browser journey do not qualify native Windows execution, provider
96-
promotion, a destination machine or long-duration SQLite operation.
94+
Restored checkpoint files use owner-only POSIX modes. Windows ACL behavior
95+
has not been independently qualified; choose a private destination directory.
96+
Native Windows tests cover the configuration-only CLI export/verify/restore and
97+
local HTTP checkpoint with UTF-8 values. The packaged browser journey, full
98+
state backup, provider promotion, a destination machine and long-duration SQLite
99+
operation remain separate qualifications.

‎loopx/cli_commands/configuration_backup.py‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,18 +43,19 @@ def handle_configuration_backup(args, *, registry_path, print_payload, output_fo
4343
path = Path(args.output).expanduser()
4444
# Keep backups immutable, including dangling symlink destinations.
4545
path.parent.mkdir(parents=True, exist_ok=True)
46-
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent, delete=False) as stream:
47-
staging = Path(stream.name)
48-
try:
46+
stream = tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent, delete=False)
47+
staging = Path(stream.name)
48+
try:
49+
with stream:
4950
stream.write(json.dumps(backup, ensure_ascii=False, indent=2) + "\n")
5051
stream.flush()
5152
os.fsync(stream.fileno())
52-
# An exclusive hard-link publication cannot replace a
53-
# destination created by another exporter.
54-
os.link(staging, path)
55-
finally:
56-
staging.unlink(missing_ok=True)
57-
if json.loads(path.read_text()) != backup:
53+
# Publish only after closing the file: Windows cannot unlink
54+
# an open staging file, and the hard link never replaces a peer.
55+
os.link(staging, path)
56+
finally:
57+
staging.unlink(missing_ok=True)
58+
if json.loads(path.read_text(encoding="utf-8")) != backup:
5859
raise RuntimeError("configuration backup export readback mismatch")
5960
payload.update(status="exported", written=True)
6061
else:

‎loopx/control_plane/configuration_backup.ts‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,16 @@ export async function restoreConfigurationBackup(value: unknown): Promise<JsonOb
8787
const receipt = {...verification, status: "restored", written: true,
8888
live_configuration_changed: false, configuration_files: files.map(([name]) => name)};
8989
await durableWriteJson(join(staging, "restore-receipt.json"), receipt);
90-
// Exclusive reservation prevents a competing restore from being overwritten.
91-
await mkdir(destination, {mode: 0o700});
92-
try { await rename(staging, destination); }
93-
catch (error) { await rmdir(destination); throw error; }
90+
if (process.platform === "win32") {
91+
// Windows refuses a directory rename into an existing destination,
92+
// so the absent-target rename publishes the complete checkpoint.
93+
await rename(staging, destination);
94+
} else {
95+
// POSIX can replace an empty directory; reserve the name exclusively.
96+
await mkdir(destination, {mode: 0o700});
97+
try { await rename(staging, destination); }
98+
catch (error) { await rmdir(destination); throw error; }
99+
}
94100
return receipt;
95101
} finally { await rm(staging, {recursive: true, force: true}); }
96102
}

‎tests/control_plane_ts/configuration_backup.test.ts‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {test} from "node:test";
22
import assert from "node:assert/strict";
3-
import {mkdtemp, mkdir, readFile, realpath, rm, symlink} from "node:fs/promises";
3+
import {mkdtemp, mkdir, readFile, readdir, realpath, rm, symlink} from "node:fs/promises";
44
import {tmpdir} from "node:os";
55
import {join} from "node:path";
66
import {captureConfigurationBackup, restoreConfigurationBackup, verifyConfigurationBackup} from "../../loopx/control_plane/configuration_backup.ts";
@@ -27,6 +27,22 @@ test("checkpoint preserves complete optional configuration and never certifies p
2727
assert.deepEqual(JSON.parse(await readFile(join(destination, "machine/configuration.json"), "utf8")),
2828
(backup.data as Record<string, unknown>).machine_configuration);
2929
await assert.rejects(restoreConfigurationBackup({...request, execute: true}), /already exists/);
30+
assert.deepEqual(JSON.parse(await readFile(join(destination, "configuration-backup.json"), "utf8")), backup);
31+
} finally {await rm(root, {recursive: true, force: true});}
32+
});
33+
34+
test("competing restores publish one complete checkpoint without replacing it", async () => {
35+
const root = await realpath(await mkdtemp(join(tmpdir(), "configuration-backup-")));
36+
try {
37+
const backup = snapshot(), destination = join(root, "restored");
38+
const request = {backup, destination, expected_sha256: backup.sha256, execute: true};
39+
const attempts = await Promise.allSettled([
40+
restoreConfigurationBackup(request), restoreConfigurationBackup(request),
41+
]);
42+
assert.equal(attempts.filter((attempt) => attempt.status === "fulfilled").length, 1);
43+
assert.equal(attempts.filter((attempt) => attempt.status === "rejected").length, 1);
44+
assert.deepEqual(JSON.parse(await readFile(join(destination, "configuration-backup.json"), "utf8")), backup);
45+
assert.deepEqual((await readdir(root)).sort(), ["restored"]);
3046
} finally {await rm(root, {recursive: true, force: true});}
3147
});
3248

@@ -46,8 +62,8 @@ test("dangling targets and symlink ancestors cannot redirect recovery", async ()
4662
try {
4763
const backup = snapshot();
4864
await mkdir(join(root, "physical"));
49-
await symlink(join(root, "physical"), join(root, "alias"), "dir");
50-
await symlink(join(root, "missing"), join(root, "dangling"));
65+
await symlink(join(root, "physical"), join(root, "alias"), process.platform === "win32" ? "junction" : "dir");
66+
await symlink(join(root, "missing"), join(root, "dangling"), process.platform === "win32" ? "junction" : "file");
5167
for (const destination of [join(root, "alias/new"), join(root, "dangling")]) {
5268
await assert.rejects(restoreConfigurationBackup({backup, destination, expected_sha256: backup.sha256, execute: true}));
5369
}

‎tests/test_configuration_backup.py‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,15 @@ def environment(tmp_path, monkeypatch):
4040

4141

4242
def cli(*args):
43+
environment = {**os.environ, "LOOPX_USAGE_PING": "0"}
44+
if os.name == "nt":
45+
# Exercise native Windows locale decoding even if pytest uses -X utf8.
46+
environment["PYTHONUTF8"] = "0"
47+
# Keep captured CLI JSON decodable when the parent pytest uses UTF-8.
48+
environment["PYTHONIOENCODING"] = "utf-8"
4349
result = subprocess.run([sys.executable, "-m", "loopx.cli", "--format", "json", *map(str, args)],
44-
env={**os.environ, "LOOPX_USAGE_PING": "0"}, capture_output=True, text=True, timeout=60)
50+
env=environment, capture_output=True, text=True,
51+
encoding="utf-8" if os.name == "nt" else None, timeout=60)
4552
return result.returncode, json.loads(result.stdout)
4653

4754

@@ -67,8 +74,11 @@ def test_cli_export_verify_restore_and_occupied_target(environment, tmp_path):
6774
code, exported = cli(*arguments, "export", "--output", output, "--execute")
6875
assert code == 0 and exported["goal_count"] == 1
6976
original = output.read_bytes()
77+
assert "完整".encode("utf-8") in original
7078
assert cli(*arguments, "export", "--output", output, "--execute")[0] == 1
71-
assert output.read_bytes() == original and output.stat().st_mode & 0o777 == 0o600
79+
assert output.read_bytes() == original
80+
if os.name != "nt":
81+
assert output.stat().st_mode & 0o777 == 0o600
7282
assert cli(*arguments, "verify", "--input", output)[0] == 0
7383
restore = (*arguments, "restore", "--input", output, "--expected-sha256", exported["sha256"], "--destination", tmp_path / "restored")
7484
assert cli(*restore)[1]["written"] is False
@@ -141,10 +151,18 @@ def post(operation, body):
141151
status, response = post("restore", body)
142152
assert status == 200, response
143153
assert response["status"] == "preview"
144-
assert not (runtime / "backups/configuration").exists()
145-
assert post("restore", {**body, "execute": True})[1]["status"] == "restored"
154+
checkpoint = runtime / "backups/configuration" / exported["sha256"]
155+
assert not checkpoint.exists()
156+
assert post("restore", {**body, "expected_sha256": "changed", "execute": True})[0] == 400
157+
assert not checkpoint.exists()
158+
status, restored = post("restore", {**body, "execute": True})
159+
assert status == 200 and restored["status"] == "restored"
160+
assert restored["checkpoint_ref"] == f"backups/configuration/{exported['sha256']}"
161+
saved = (checkpoint / "configuration-backup.json").read_bytes()
162+
assert json.loads(saved.decode("utf-8")) == exported["backup"]
163+
assert "完整".encode("utf-8") in saved
146164
assert post("restore", {**body, "execute": True})[0] == 400
147-
assert post("restore", {**body, "expected_sha256": "changed"})[0] == 400
165+
assert (checkpoint / "configuration-backup.json").read_bytes() == saved
148166
finally:
149167
server.shutdown()
150168
server.server_close()

0 commit comments

Comments
 (0)