From dd8c91a946a9c836965818d8cc35b8f8bce74658 Mon Sep 17 00:00:00 2001 From: fy59 Date: Thu, 17 Sep 2026 15:47:15 +0200 Subject: [PATCH] fix(sync): package user service helpers --- CHANGELOG.md | 4 ++ docs/current_state.md | 3 + docs/sync_rollout_readiness.md | 4 ++ docs/tests.md | 6 ++ tests/test_sync_deployment_tools.py | 101 +++++++++++++++++++++------- tools/package_sync_candidate.py | 18 +++++ 6 files changed, 113 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7bd0ac..a81ce12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased — 0.1.2 +- Packaged `trainlog-sync-once` and `trainlog-syncd` with the relocatable sync + candidate so user services cannot mix a versioned install with live-checkout + helpers during rollout. + - Closed the native candidate warning gate with checked path and HTTP response bounds, strict GCC and Clang builds, full Clang ASan/UBSan validation, and a stable concurrent object-I/O MTP double. Rebuilt the desktop, current Android diff --git a/docs/current_state.md b/docs/current_state.md index 1e70b10..556bce4 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -14,6 +14,9 @@ Clang ASan/UBSan build passes it with leak detection. Bounded path assembly and the Web sync accepted response reject insufficient capacity without publishing partial output. Fresh desktop, current-Android and schema-v17 bridge candidates were rebuilt from commit `2c5a8be2827e31fc5be2accb65073bfeb79d6068`. +The rollout packager now keeps the legacy request daemon and one-shot helper in +the same relocatable, hashed bundle as the Web generation coordinator, avoiding +mixed-checkout user-service execution during the controlled rollout. Snapshot date: **2026-09-17**. diff --git a/docs/sync_rollout_readiness.md b/docs/sync_rollout_readiness.md index b972a63..f503981 100644 --- a/docs/sync_rollout_readiness.md +++ b/docs/sync_rollout_readiness.md @@ -22,6 +22,10 @@ the separately authorized rollout are complete. 1. Build and validate one revision, then run `python3 tools/package_sync_candidate.py --output `. Its inventory records hashes, schemas, protocols, entry points and dependencies. + The relocatable bundle includes `trainlog`, `trainlog-sync-once`, + `trainlog-syncd`, the production generation MTP adapter and their matching + Python codecs. User units must point only into one versioned bundle; they + must not combine a packaged binary with helpers from a live checkout. 2. `assembleDebug` is development evidence only. Rollout requires the existing release signing identity, verification of its public certificate and a valid increasing `versionCode`; this mission reads no signing secret. diff --git a/docs/tests.md b/docs/tests.md index de5716f..691605b 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -1333,6 +1333,12 @@ JDK 17. The real Firefox/object-I/O-boundary MTP conversation also passes after formatting. A source comparison against the pre-readability commit confirms that the scoped test names and assertion counts are unchanged. +The deployment-tools regression also constructs the relocatable candidate and +requires its inventory to include the packaged `trainlog-sync-once` binary, +the `trainlog-syncd` launcher and daemon source, and the generation worker. +This prevents a user unit from silently executing a helper from a different +checkout than the installed desktop candidate. + The isolated JDK 17 harness reports **220 Android tests: 215 passed, five skipped, zero failures/errors**. The skips are optional, externally supplied copy gates: `BridgeBackupMigrationTest.verifiedSchemaSeventeenBridgeBackupMigratesThroughCurrentOwner`, diff --git a/tests/test_sync_deployment_tools.py b/tests/test_sync_deployment_tools.py index 35532fd..5c9b099 100644 --- a/tests/test_sync_deployment_tools.py +++ b/tests/test_sync_deployment_tools.py @@ -1,29 +1,84 @@ -import json, sqlite3, subprocess, tempfile, unittest +import json +import sqlite3 +import subprocess +import tempfile +import unittest from pathlib import Path -ROOT=Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[1] + class DeploymentToolsTest(unittest.TestCase): - def test_sqlite_backup_captures_committed_wal_and_restores_invariants(self): - with tempfile.TemporaryDirectory() as directory: - root=Path(directory);source=root/"source.db";backup=root/"backup.db" - db=sqlite3.connect(source);db.execute("PRAGMA journal_mode=WAL");db.execute("PRAGMA foreign_keys=ON") - db.executescript("CREATE TABLE parent(id INTEGER PRIMARY KEY);CREATE TABLE child(parent_id INTEGER REFERENCES parent);INSERT INTO parent VALUES(7);INSERT INTO child VALUES(7);");db.commit() - result=subprocess.run(["python3",str(ROOT/"tools/backup_trainlog_sqlite.py"),str(source),str(backup)],capture_output=True,text=True) - self.assertEqual(result.returncode,0,result.stderr) - with sqlite3.connect(backup) as restored: - self.assertEqual(restored.execute("SELECT * FROM child").fetchall(),[(7,)]) - self.assertEqual(restored.execute("PRAGMA integrity_check").fetchone(),("ok",)) - db.close() + def test_sqlite_backup_captures_committed_wal_and_restores_invariants(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source.db" + backup = root / "backup.db" + database = sqlite3.connect(source) + database.execute("PRAGMA journal_mode=WAL") + database.execute("PRAGMA foreign_keys=ON") + database.executescript( + "CREATE TABLE parent(id INTEGER PRIMARY KEY);" + "CREATE TABLE child(parent_id INTEGER REFERENCES parent);" + "INSERT INTO parent VALUES(7);" + "INSERT INTO child VALUES(7);" + ) + database.commit() + result = subprocess.run( + [ + "python3", + str(ROOT / "tools/backup_trainlog_sqlite.py"), + str(source), + str(backup), + ], + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + with sqlite3.connect(backup) as restored: + self.assertEqual( + restored.execute("SELECT * FROM child").fetchall(), + [(7,)], + ) + self.assertEqual( + restored.execute("PRAGMA integrity_check").fetchone(), + ("ok",), + ) + database.close() - def test_candidate_is_self_contained_and_hashed(self): - with tempfile.TemporaryDirectory() as directory: - output=Path(directory)/"candidate" - result=subprocess.run(["python3",str(ROOT/"tools/package_sync_candidate.py"),"--output",str(output)],capture_output=True,text=True) - self.assertEqual(result.returncode,0,result.stderr) - inventory=json.loads((output/"candidate-inventory.json").read_text()) - self.assertEqual(inventory["product_version"],"0.1.2") - self.assertTrue((output/"bin/trainlog").is_file()) - self.assertTrue(any(row["path"]=="tools/sync_peer_worker.py" for row in inventory["files"])) + def test_candidate_is_self_contained_and_hashed(self): + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "candidate" + result = subprocess.run( + [ + "python3", + str(ROOT / "tools/package_sync_candidate.py"), + "--output", + str(output), + ], + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + inventory = json.loads((output / "candidate-inventory.json").read_text()) + self.assertEqual(inventory["product_version"], "0.1.2") + self.assertTrue((output / "bin/trainlog").is_file()) + self.assertTrue((output / "bin/trainlog-sync-once").is_file()) + self.assertTrue((output / "bin/trainlog-syncd").is_file()) + self.assertTrue((output / "libexec/trainlog-sync-once").is_file()) + self.assertTrue( + any( + row["path"] == "tools/trainlog_syncd.py" + for row in inventory["files"] + ) + ) + self.assertTrue( + any( + row["path"] == "tools/sync_peer_worker.py" + for row in inventory["files"] + ) + ) -if __name__=="__main__": unittest.main() + +if __name__ == "__main__": + unittest.main() diff --git a/tools/package_sync_candidate.py b/tools/package_sync_candidate.py index 6730600..0327007 100644 --- a/tools/package_sync_candidate.py +++ b/tools/package_sync_candidate.py @@ -11,6 +11,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] RUNTIME_TOOLS = [ + "trainlog_syncd.py", "sync_orchestrator.py", "sync_peer_worker.py", "sync_generation_exchange.py", @@ -49,16 +50,20 @@ def main() -> int: if args.output.exists(): raise RuntimeError("candidate output already exists") binary = args.build / "tui/trainlog" + sync_once = args.build / "tui/trainlog-sync-once" mtp_adapter = args.build / "tui/trainlog-generation-mtp-adapter" if not binary.is_file(): raise RuntimeError("compiled trainlog executable is missing") if not mtp_adapter.is_file(): raise RuntimeError("compiled generation MTP adapter is missing") + if not sync_once.is_file(): + raise RuntimeError("compiled legacy synchronization helper is missing") (args.output / "bin").mkdir(parents=True) (args.output / "libexec").mkdir(parents=True) (args.output / "tools").mkdir(parents=True) (args.output / "catalog").mkdir(parents=True) shutil.copy2(binary, args.output / "libexec/trainlog") + shutil.copy2(sync_once, args.output / "libexec/trainlog-sync-once") shutil.copy2(mtp_adapter, args.output / "libexec/trainlog-generation-mtp-adapter") for name in RUNTIME_TOOLS: shutil.copy2(ROOT / "tools" / name, args.output / "tools" / name) @@ -68,6 +73,14 @@ def main() -> int: launcher.write_text( '#!/bin/sh\nset -eu\nroot=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)\nexport TRAINLOG_SYNC_TOOLS_DIR="$root/tools"\nexport TRAINLOG_SYNC_MTP_ADAPTER="$root/libexec/trainlog-generation-mtp-adapter"\nexec "$root/libexec/trainlog" "$@"\n' ) + sync_once_launcher = args.output / "bin/trainlog-sync-once" + sync_once_launcher.write_text( + '#!/bin/sh\nset -eu\nroot=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)\nexec "$root/libexec/trainlog-sync-once" "$@"\n' + ) + syncd_launcher = args.output / "bin/trainlog-syncd" + syncd_launcher.write_text( + '#!/bin/sh\nset -eu\nroot=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)\nexec python3 "$root/tools/trainlog_syncd.py" --sync-once "$root/bin/trainlog-sync-once" "$@"\n' + ) files = sorted(path for path in args.output.rglob("*") if path.is_file()) inventory = { "format": "trainlog-sync-candidate-inventory", @@ -88,6 +101,8 @@ def main() -> int: ], "entry_points": [ "bin/trainlog", + "bin/trainlog-sync-once", + "bin/trainlog-syncd", "libexec/trainlog-generation-mtp-adapter", "tools/sync_orchestrator.py", ], @@ -113,7 +128,10 @@ def main() -> int: json.dumps(inventory, indent=2, sort_keys=True) + "\n" ) os.chmod(args.output / "bin/trainlog", 0o755) + os.chmod(args.output / "bin/trainlog-sync-once", 0o755) + os.chmod(args.output / "bin/trainlog-syncd", 0o755) os.chmod(args.output / "libexec/trainlog", 0o755) + os.chmod(args.output / "libexec/trainlog-sync-once", 0o755) os.chmod(args.output / "libexec/trainlog-generation-mtp-adapter", 0o755) return 0