feat(peer)!: cut over to authenticated catalog sharing
Replace address-only trust and pushed peer state with installation identities, SPKI-pinned QUIC, candidate-only discovery, and bounded responder-owned protocol-8 pulls. The runtime now owns each network generation and all admitted work through shutdown. Add exact bundled content identities, reproducible manifest publishing, capability-confined downloads, streaming BLAKE3 verification, quarantine and retry, and crash-recoverable download and install transactions. Ship generated fixture catalogs and fail closed when production manifests are absent. The Tauri backend exposes durable sharing policy, redacted identity state, and attempt-keyed transfer snapshots. Frontend consumption follows in the next commit. Repository-wide test certificates and protocol-7 paths are removed. BREAKING CHANGE: peers must use protocol 8 and exact catalog content artifacts; protocol-7 frames and shared-certificate identities are no longer accepted. Test Plan: - `just test` -- passed on the completed stack (708 workspace tests) - `just clippy` -- passed on the completed stack - `just build` -- passed with fixture catalogs on the completed stack - `just catalog-check-production` -- failed closed because the external production manifest corpus is absent - `git diff --cached --check` -- passed
This commit is contained in:
@@ -19,6 +19,13 @@ eyre = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
|
||||
[target."cfg(unix)".dependencies]
|
||||
rustix = { workspace = true, features = ["fs"] }
|
||||
|
||||
[target."cfg(unix)".dev-dependencies]
|
||||
rustix = { workspace = true, features = ["process"] }
|
||||
|
||||
[lints.clippy]
|
||||
needless_pass_by_value = "allow"
|
||||
|
||||
@@ -11,9 +11,10 @@ RUN apt-get update \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=build /work/target/release/lanspread-peer-cli /usr/local/bin/lanspread-peer-cli
|
||||
COPY crates/lanspread-tauri-deno-ts/src-tauri/game.db /app/game.db
|
||||
COPY crates/lanspread-peer-cli/catalogs/default/game.db /app/game.db
|
||||
COPY crates/lanspread-peer-cli/catalogs/default/manifests /app/manifests
|
||||
COPY crates/lanspread-tauri-deno-ts/src-tauri/binaries/unrar-x86_64-unknown-linux-gnu /usr/local/bin/unrar
|
||||
RUN chmod +x /usr/local/bin/unrar
|
||||
|
||||
ENTRYPOINT ["lanspread-peer-cli"]
|
||||
CMD ["--games-dir", "/games", "--state-dir", "/state", "--catalog-db", "/app/game.db"]
|
||||
CMD ["--games-dir", "/games", "--state-dir", "/state", "--catalog-db", "/app/game.db", "--manifests-dir", "/app/manifests"]
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"game_id": "alienswarm",
|
||||
"game_version": "20190317",
|
||||
"chunk_size": 134217728,
|
||||
"files": [
|
||||
{
|
||||
"canonical_path": "alienswarm.eti",
|
||||
"kind": "file",
|
||||
"size": 125829913,
|
||||
"file_blake3": "77fd653681a1643973dd59b981417eed231304d1f785b1b529c1c28ce94d0a5b",
|
||||
"chunk_blake3": [
|
||||
"77fd653681a1643973dd59b981417eed231304d1f785b1b529c1c28ce94d0a5b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"canonical_path": "notes.txt",
|
||||
"kind": "file",
|
||||
"size": 34,
|
||||
"file_blake3": "9a2cbf6fa18632be6e6262671952f781a3b53f41c3ca31219b7e19763d40a65e",
|
||||
"chunk_blake3": [
|
||||
"9a2cbf6fa18632be6e6262671952f781a3b53f41c3ca31219b7e19763d40a65e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"canonical_path": "version.ini",
|
||||
"kind": "file",
|
||||
"size": 8,
|
||||
"file_blake3": "813c1bf52f38019051bfbc2cbfcdbf8620a46124ddb14dc7a55df9daa6186bee",
|
||||
"chunk_blake3": [
|
||||
"813c1bf52f38019051bfbc2cbfcdbf8620a46124ddb14dc7a55df9daa6186bee"
|
||||
]
|
||||
}
|
||||
],
|
||||
"streamed_install_files": [
|
||||
{
|
||||
"canonical_path": "target/peer-cli-large-fixture/alienswarm-payload",
|
||||
"kind": "directory",
|
||||
"size": 0,
|
||||
"file_blake3": null
|
||||
},
|
||||
{
|
||||
"canonical_path": "target/peer-cli-large-fixture/alienswarm-payload/payload-a.bin",
|
||||
"kind": "file",
|
||||
"size": 41943040,
|
||||
"file_blake3": "d9d3cd4605ec26ada09b3e618546c45dfd8b7dd42ba7d1e825cff3062037cedf"
|
||||
},
|
||||
{
|
||||
"canonical_path": "target/peer-cli-large-fixture/alienswarm-payload/payload-b.bin",
|
||||
"kind": "file",
|
||||
"size": 41943040,
|
||||
"file_blake3": "3835c341be211b8549328f31b82732b88e47e18db4748e296b9e3fdbad339c07"
|
||||
},
|
||||
{
|
||||
"canonical_path": "target/peer-cli-large-fixture/alienswarm-payload/payload-c.bin",
|
||||
"kind": "file",
|
||||
"size": 41943040,
|
||||
"file_blake3": "4ba18b14e0ba7e5822aa1c8d494a1646b82f53581f65217bb6dcee3f69e7ba7d"
|
||||
}
|
||||
],
|
||||
"content_id": "e871472e2dc154623369c7a05825d65f50f1412509569dab0d15c4565489dcfc"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"game_id": "bf1942",
|
||||
"game_version": "20160130",
|
||||
"chunk_size": 134217728,
|
||||
"files": [
|
||||
{
|
||||
"canonical_path": "bf1942.eti",
|
||||
"kind": "file",
|
||||
"size": 3146118,
|
||||
"file_blake3": "65f7729f0a353b20d3996331ef6c1813ce2259e90d3632eac2bae463700c1126",
|
||||
"chunk_blake3": [
|
||||
"65f7729f0a353b20d3996331ef6c1813ce2259e90d3632eac2bae463700c1126"
|
||||
]
|
||||
},
|
||||
{
|
||||
"canonical_path": "version.ini",
|
||||
"kind": "file",
|
||||
"size": 8,
|
||||
"file_blake3": "37c38c7fe5ec9239e6b970165c5793fe6d503bee41344abd2a0e329c7afbb39b",
|
||||
"chunk_blake3": [
|
||||
"37c38c7fe5ec9239e6b970165c5793fe6d503bee41344abd2a0e329c7afbb39b"
|
||||
]
|
||||
}
|
||||
],
|
||||
"streamed_install_files": [
|
||||
{
|
||||
"canonical_path": "bin",
|
||||
"kind": "directory",
|
||||
"size": 0,
|
||||
"file_blake3": null
|
||||
},
|
||||
{
|
||||
"canonical_path": "bin/bf1942-payload.bin",
|
||||
"kind": "file",
|
||||
"size": 2097152,
|
||||
"file_blake3": "de7d369a819879597920fa6eea41e421cb5b56763e279b4cf95cf9c2338f7af2"
|
||||
},
|
||||
{
|
||||
"canonical_path": "data",
|
||||
"kind": "directory",
|
||||
"size": 0,
|
||||
"file_blake3": null
|
||||
},
|
||||
{
|
||||
"canonical_path": "data/bf1942-assets.dat",
|
||||
"kind": "file",
|
||||
"size": 1048576,
|
||||
"file_blake3": "cfbe618ef17da6a5197912649bf31619bfa02db8db60c176f82473bab37d46d9"
|
||||
}
|
||||
],
|
||||
"content_id": "157b952fd2226fc6edc90b698606510956301ff867e6ffb11c724584b1fa9b5a"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"game_id": "bfbc2",
|
||||
"game_version": "20210416",
|
||||
"chunk_size": 134217728,
|
||||
"files": [
|
||||
{
|
||||
"canonical_path": "bfbc2.eti",
|
||||
"kind": "file",
|
||||
"size": 3146114,
|
||||
"file_blake3": "2e4022096dfd3114ec7bacf69c6c25bd83b9a6d3af71e81df4ba8584fc23a82b",
|
||||
"chunk_blake3": [
|
||||
"2e4022096dfd3114ec7bacf69c6c25bd83b9a6d3af71e81df4ba8584fc23a82b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"canonical_path": "version.ini",
|
||||
"kind": "file",
|
||||
"size": 8,
|
||||
"file_blake3": "6eacd1c0e8efe8f809658ee66b9272d32daa1daadd904469ce5f7d128d23c9d5",
|
||||
"chunk_blake3": [
|
||||
"6eacd1c0e8efe8f809658ee66b9272d32daa1daadd904469ce5f7d128d23c9d5"
|
||||
]
|
||||
}
|
||||
],
|
||||
"streamed_install_files": [
|
||||
{
|
||||
"canonical_path": "bin",
|
||||
"kind": "directory",
|
||||
"size": 0,
|
||||
"file_blake3": null
|
||||
},
|
||||
{
|
||||
"canonical_path": "bin/bfbc2-payload.bin",
|
||||
"kind": "file",
|
||||
"size": 2097152,
|
||||
"file_blake3": "4d6d0c9ee250f23e4828fd50872eecf945c022d0d82cc1a11bf25c96ef0ec807"
|
||||
},
|
||||
{
|
||||
"canonical_path": "data",
|
||||
"kind": "directory",
|
||||
"size": 0,
|
||||
"file_blake3": null
|
||||
},
|
||||
{
|
||||
"canonical_path": "data/bfbc2-assets.dat",
|
||||
"kind": "file",
|
||||
"size": 1048576,
|
||||
"file_blake3": "16daa7902e05888c38b984bac96bb5af496e43331bd3d8e858f019c8fd09dda5"
|
||||
}
|
||||
],
|
||||
"content_id": "ddd30de1727ec7bb37dad961217de619b514a71f47ccb4d7acbfcb8ad21f50d3"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"schema_version":1,"games":{"alienswarm":{"game_version":"20190317","content_id":"e871472e2dc154623369c7a05825d65f50f1412509569dab0d15c4565489dcfc","supports_streamed_install":true},"bf1942":{"game_version":"20160130","content_id":"157b952fd2226fc6edc90b698606510956301ff867e6ffb11c724584b1fa9b5a","supports_streamed_install":true},"bfbc2":{"game_version":"20210416","content_id":"ddd30de1727ec7bb37dad961217de619b514a71f47ccb4d7acbfcb8ad21f50d3","supports_streamed_install":true},"cnc4":{"game_version":"20170204","content_id":"d356f42b1989d6c36011ff3c5f52ac663bcc0eb8efe144872f11e8d8b0e2e9a1","supports_streamed_install":true},"cnctw":{"game_version":"20160128","content_id":"f01e4b38e052cbd9ba1c468e31af78cf95f0c9bc3371875289cb3ec2e8be8067","supports_streamed_install":true},"cod5":{"game_version":"20160920","content_id":"56e7bd1e80e32d50a1f8022766f87c68c0dfb6f7ad041485641b8ffb7098ca81","supports_streamed_install":true},"cod6":{"game_version":"20200315","content_id":"dc2cfa0e4da3fdc7138dc37930a8503ead08883faa0f56f59598489b48de3153","supports_streamed_install":true},"coh":{"game_version":"20200907","content_id":"7cf80604d1811b8683c415a60415a6e980bb25e42146b4b474324d47e068f34d","supports_streamed_install":true},"css":{"game_version":"20240623","content_id":"9d8a0aa232c27023b41491fa0218abd0592a3e0b3e5b5168b337a608e3cf8507","supports_streamed_install":true},"ggoo":{"game_version":"20200721","content_id":"30a6358eff3b0ed6d797aee10766ca69741eb1198f83d341d0876b5ce1009ae0","supports_streamed_install":true}}}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"game_id": "cnc4",
|
||||
"game_version": "20170204",
|
||||
"chunk_size": 134217728,
|
||||
"files": [
|
||||
{
|
||||
"canonical_path": "cnc4.eti",
|
||||
"kind": "file",
|
||||
"size": 3146110,
|
||||
"file_blake3": "e96019bec5b92e3f5a241d1a5bd1a1608c4bb6b44e2767a7eaa01ae46a1d35d9",
|
||||
"chunk_blake3": [
|
||||
"e96019bec5b92e3f5a241d1a5bd1a1608c4bb6b44e2767a7eaa01ae46a1d35d9"
|
||||
]
|
||||
},
|
||||
{
|
||||
"canonical_path": "version.ini",
|
||||
"kind": "file",
|
||||
"size": 8,
|
||||
"file_blake3": "6744ce6acc6d7548c8a87eea79a5bb7480fe3848cfab7f272e2bcbe43d652d3f",
|
||||
"chunk_blake3": [
|
||||
"6744ce6acc6d7548c8a87eea79a5bb7480fe3848cfab7f272e2bcbe43d652d3f"
|
||||
]
|
||||
}
|
||||
],
|
||||
"streamed_install_files": [
|
||||
{
|
||||
"canonical_path": "bin",
|
||||
"kind": "directory",
|
||||
"size": 0,
|
||||
"file_blake3": null
|
||||
},
|
||||
{
|
||||
"canonical_path": "bin/cnc4-payload.bin",
|
||||
"kind": "file",
|
||||
"size": 2097152,
|
||||
"file_blake3": "f1394ba123b31bbdbd109d0a241d0d580b99e78750f1cff7838caf8e62f6e3e0"
|
||||
},
|
||||
{
|
||||
"canonical_path": "data",
|
||||
"kind": "directory",
|
||||
"size": 0,
|
||||
"file_blake3": null
|
||||
},
|
||||
{
|
||||
"canonical_path": "data/cnc4-assets.dat",
|
||||
"kind": "file",
|
||||
"size": 1048576,
|
||||
"file_blake3": "ee16d8c5e60a48ce5fabfd230bf889865e847eb8a0cc5dd3d7615291fe04c043"
|
||||
}
|
||||
],
|
||||
"content_id": "d356f42b1989d6c36011ff3c5f52ac663bcc0eb8efe144872f11e8d8b0e2e9a1"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"game_id": "cnctw",
|
||||
"game_version": "20160128",
|
||||
"chunk_size": 134217728,
|
||||
"files": [
|
||||
{
|
||||
"canonical_path": "cnctw.eti",
|
||||
"kind": "file",
|
||||
"size": 3146114,
|
||||
"file_blake3": "f4d31de3bcff86de8c78f766f1e7809ffd5d47a951742ac9db6a97551d84eb6f",
|
||||
"chunk_blake3": [
|
||||
"f4d31de3bcff86de8c78f766f1e7809ffd5d47a951742ac9db6a97551d84eb6f"
|
||||
]
|
||||
},
|
||||
{
|
||||
"canonical_path": "version.ini",
|
||||
"kind": "file",
|
||||
"size": 8,
|
||||
"file_blake3": "995b0db832e29dbb6025f31b7aa8f1e842151971fac3ea7d6cac2c287f2ce64a",
|
||||
"chunk_blake3": [
|
||||
"995b0db832e29dbb6025f31b7aa8f1e842151971fac3ea7d6cac2c287f2ce64a"
|
||||
]
|
||||
}
|
||||
],
|
||||
"streamed_install_files": [
|
||||
{
|
||||
"canonical_path": "bin",
|
||||
"kind": "directory",
|
||||
"size": 0,
|
||||
"file_blake3": null
|
||||
},
|
||||
{
|
||||
"canonical_path": "bin/cnctw-payload.bin",
|
||||
"kind": "file",
|
||||
"size": 2097152,
|
||||
"file_blake3": "495fcaac274d7c6cf9a2db7587280b73d4c18d7ac473305082c4fe1a0b93ec82"
|
||||
},
|
||||
{
|
||||
"canonical_path": "data",
|
||||
"kind": "directory",
|
||||
"size": 0,
|
||||
"file_blake3": null
|
||||
},
|
||||
{
|
||||
"canonical_path": "data/cnctw-assets.dat",
|
||||
"kind": "file",
|
||||
"size": 1048576,
|
||||
"file_blake3": "372e932a9316a3c76517520b91540cc4eb4035e8e5726a0df4910c018117f344"
|
||||
}
|
||||
],
|
||||
"content_id": "f01e4b38e052cbd9ba1c468e31af78cf95f0c9bc3371875289cb3ec2e8be8067"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"game_id": "cod5",
|
||||
"game_version": "20160920",
|
||||
"chunk_size": 134217728,
|
||||
"files": [
|
||||
{
|
||||
"canonical_path": "cod5.eti",
|
||||
"kind": "file",
|
||||
"size": 3146110,
|
||||
"file_blake3": "53fa2d2c9daade4cc16a4b562643f18916cfe2533696d5c5db7dfe1f4a48eddf",
|
||||
"chunk_blake3": [
|
||||
"53fa2d2c9daade4cc16a4b562643f18916cfe2533696d5c5db7dfe1f4a48eddf"
|
||||
]
|
||||
},
|
||||
{
|
||||
"canonical_path": "version.ini",
|
||||
"kind": "file",
|
||||
"size": 8,
|
||||
"file_blake3": "caec0ebe8cf7509acb9b9feefbd704c204ff2689a8fdefcaf8f477fd53c54163",
|
||||
"chunk_blake3": [
|
||||
"caec0ebe8cf7509acb9b9feefbd704c204ff2689a8fdefcaf8f477fd53c54163"
|
||||
]
|
||||
}
|
||||
],
|
||||
"streamed_install_files": [
|
||||
{
|
||||
"canonical_path": "bin",
|
||||
"kind": "directory",
|
||||
"size": 0,
|
||||
"file_blake3": null
|
||||
},
|
||||
{
|
||||
"canonical_path": "bin/cod5-payload.bin",
|
||||
"kind": "file",
|
||||
"size": 2097152,
|
||||
"file_blake3": "169fa6cc2a0a802b72612ae35444735bf592c30bc787739b4c2307a8fd67a95b"
|
||||
},
|
||||
{
|
||||
"canonical_path": "data",
|
||||
"kind": "directory",
|
||||
"size": 0,
|
||||
"file_blake3": null
|
||||
},
|
||||
{
|
||||
"canonical_path": "data/cod5-assets.dat",
|
||||
"kind": "file",
|
||||
"size": 1048576,
|
||||
"file_blake3": "f122d70e41a567ddf06b77d650683db42f5ecb1bdf5648366f16486e3adb7cd7"
|
||||
}
|
||||
],
|
||||
"content_id": "56e7bd1e80e32d50a1f8022766f87c68c0dfb6f7ad041485641b8ffb7098ca81"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"game_id": "cod6",
|
||||
"game_version": "20200315",
|
||||
"chunk_size": 134217728,
|
||||
"files": [
|
||||
{
|
||||
"canonical_path": "cod6.eti",
|
||||
"kind": "file",
|
||||
"size": 3146110,
|
||||
"file_blake3": "ce096cadb67834be368fa1fe2141426810591a0febe6c4e960d729a4a436d30f",
|
||||
"chunk_blake3": [
|
||||
"ce096cadb67834be368fa1fe2141426810591a0febe6c4e960d729a4a436d30f"
|
||||
]
|
||||
},
|
||||
{
|
||||
"canonical_path": "version.ini",
|
||||
"kind": "file",
|
||||
"size": 8,
|
||||
"file_blake3": "fb541cd98ca1ce773b04cd44d27f182830ee5e04e63f0a268486826f5b33c424",
|
||||
"chunk_blake3": [
|
||||
"fb541cd98ca1ce773b04cd44d27f182830ee5e04e63f0a268486826f5b33c424"
|
||||
]
|
||||
}
|
||||
],
|
||||
"streamed_install_files": [
|
||||
{
|
||||
"canonical_path": "bin",
|
||||
"kind": "directory",
|
||||
"size": 0,
|
||||
"file_blake3": null
|
||||
},
|
||||
{
|
||||
"canonical_path": "bin/cod6-payload.bin",
|
||||
"kind": "file",
|
||||
"size": 2097152,
|
||||
"file_blake3": "491b9181f503d31917709d5429e1f52686d52bf13562faedbc84031885aeca13"
|
||||
},
|
||||
{
|
||||
"canonical_path": "data",
|
||||
"kind": "directory",
|
||||
"size": 0,
|
||||
"file_blake3": null
|
||||
},
|
||||
{
|
||||
"canonical_path": "data/cod6-assets.dat",
|
||||
"kind": "file",
|
||||
"size": 1048576,
|
||||
"file_blake3": "ac874e3ac3438dee4c2e42165ead2fb9f84210760d1645294e4eed8ec6792cf4"
|
||||
}
|
||||
],
|
||||
"content_id": "dc2cfa0e4da3fdc7138dc37930a8503ead08883faa0f56f59598489b48de3153"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"game_id": "coh",
|
||||
"game_version": "20200907",
|
||||
"chunk_size": 134217728,
|
||||
"files": [
|
||||
{
|
||||
"canonical_path": "coh.eti",
|
||||
"kind": "file",
|
||||
"size": 3146106,
|
||||
"file_blake3": "51c8a7d80b2424c262f55beabbc26b31a878ab3b5df7b8470b1f5838d02fa214",
|
||||
"chunk_blake3": [
|
||||
"51c8a7d80b2424c262f55beabbc26b31a878ab3b5df7b8470b1f5838d02fa214"
|
||||
]
|
||||
},
|
||||
{
|
||||
"canonical_path": "version.ini",
|
||||
"kind": "file",
|
||||
"size": 8,
|
||||
"file_blake3": "5f033915cb29613e39a19fba14b466689a996a621c8b3e3f1f3bd133bed9a50a",
|
||||
"chunk_blake3": [
|
||||
"5f033915cb29613e39a19fba14b466689a996a621c8b3e3f1f3bd133bed9a50a"
|
||||
]
|
||||
}
|
||||
],
|
||||
"streamed_install_files": [
|
||||
{
|
||||
"canonical_path": "bin",
|
||||
"kind": "directory",
|
||||
"size": 0,
|
||||
"file_blake3": null
|
||||
},
|
||||
{
|
||||
"canonical_path": "bin/coh-payload.bin",
|
||||
"kind": "file",
|
||||
"size": 2097152,
|
||||
"file_blake3": "cf081a2e71ae96aa1505605e75ae3651dfc59e395e7fcfd5f5520fa1eb133fb3"
|
||||
},
|
||||
{
|
||||
"canonical_path": "data",
|
||||
"kind": "directory",
|
||||
"size": 0,
|
||||
"file_blake3": null
|
||||
},
|
||||
{
|
||||
"canonical_path": "data/coh-assets.dat",
|
||||
"kind": "file",
|
||||
"size": 1048576,
|
||||
"file_blake3": "ac5eadb10f22a57f9072c19d141212cdb847fee759f38af31c5a96a6ff80d748"
|
||||
}
|
||||
],
|
||||
"content_id": "7cf80604d1811b8683c415a60415a6e980bb25e42146b4b474324d47e068f34d"
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"game_id": "css",
|
||||
"game_version": "20240623",
|
||||
"chunk_size": 134217728,
|
||||
"files": [
|
||||
{
|
||||
"canonical_path": "css.eti",
|
||||
"kind": "file",
|
||||
"size": 654,
|
||||
"file_blake3": "a5123abeb6b462d0fba6e58516cd2e6a7986eb18bd9084134c07c8422552bccd",
|
||||
"chunk_blake3": [
|
||||
"a5123abeb6b462d0fba6e58516cd2e6a7986eb18bd9084134c07c8422552bccd"
|
||||
]
|
||||
},
|
||||
{
|
||||
"canonical_path": "version.ini",
|
||||
"kind": "file",
|
||||
"size": 8,
|
||||
"file_blake3": "6568056191954d307f80dcf71bdb0052a6873a770098141d34edd801bcaf2194",
|
||||
"chunk_blake3": [
|
||||
"6568056191954d307f80dcf71bdb0052a6873a770098141d34edd801bcaf2194"
|
||||
]
|
||||
}
|
||||
],
|
||||
"streamed_install_files": [
|
||||
{
|
||||
"canonical_path": "engine",
|
||||
"kind": "directory",
|
||||
"size": 0,
|
||||
"file_blake3": null
|
||||
},
|
||||
{
|
||||
"canonical_path": "engine/bin",
|
||||
"kind": "directory",
|
||||
"size": 0,
|
||||
"file_blake3": null
|
||||
},
|
||||
{
|
||||
"canonical_path": "engine/bin/win64",
|
||||
"kind": "directory",
|
||||
"size": 0,
|
||||
"file_blake3": null
|
||||
},
|
||||
{
|
||||
"canonical_path": "engine/bin/win64/steam_settings",
|
||||
"kind": "directory",
|
||||
"size": 0,
|
||||
"file_blake3": null
|
||||
},
|
||||
{
|
||||
"canonical_path": "engine/bin/win64/steam_settings/SmartSteamEmu.ini",
|
||||
"kind": "file",
|
||||
"size": 71,
|
||||
"file_blake3": "6b2e288b261c0d2464f6f422cc1038b4c49de58daf89dcc758481adfd99dc026"
|
||||
},
|
||||
{
|
||||
"canonical_path": "profiles",
|
||||
"kind": "directory",
|
||||
"size": 0,
|
||||
"file_blake3": null
|
||||
},
|
||||
{
|
||||
"canonical_path": "profiles/local",
|
||||
"kind": "directory",
|
||||
"size": 0,
|
||||
"file_blake3": null
|
||||
},
|
||||
{
|
||||
"canonical_path": "profiles/local/account_name.txt",
|
||||
"kind": "file",
|
||||
"size": 11,
|
||||
"file_blake3": "626d0787808ca0087e3d959495e57e41d15c7fff51113f6627aa908825c29830"
|
||||
},
|
||||
{
|
||||
"canonical_path": "profiles/local/language.txt",
|
||||
"kind": "file",
|
||||
"size": 7,
|
||||
"file_blake3": "3e59c7a7502a0a63099171b709c5eed0191275c51d83de063d4cac191bac4813"
|
||||
},
|
||||
{
|
||||
"canonical_path": "readme.txt",
|
||||
"kind": "file",
|
||||
"size": 17,
|
||||
"file_blake3": "815192d90dcdecf7dff970c24ae3b20b18351d75da2aba226ba6f6e0bac2c7b6"
|
||||
}
|
||||
],
|
||||
"content_id": "9d8a0aa232c27023b41491fa0218abd0592a3e0b3e5b5168b337a608e3cf8507"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"game_id": "ggoo",
|
||||
"game_version": "20200721",
|
||||
"chunk_size": 134217728,
|
||||
"files": [
|
||||
{
|
||||
"canonical_path": "ggoo.eti",
|
||||
"kind": "file",
|
||||
"size": 3146110,
|
||||
"file_blake3": "ca521fcb90514c2d4c6d33fe79d1252066547f5f83f5a98bf92d93e473fbebe4",
|
||||
"chunk_blake3": [
|
||||
"ca521fcb90514c2d4c6d33fe79d1252066547f5f83f5a98bf92d93e473fbebe4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"canonical_path": "version.ini",
|
||||
"kind": "file",
|
||||
"size": 8,
|
||||
"file_blake3": "a5354e30ec514c1a2b1d7db06e88345a861b5cd90dc7f287a44c917612f851aa",
|
||||
"chunk_blake3": [
|
||||
"a5354e30ec514c1a2b1d7db06e88345a861b5cd90dc7f287a44c917612f851aa"
|
||||
]
|
||||
}
|
||||
],
|
||||
"streamed_install_files": [
|
||||
{
|
||||
"canonical_path": "bin",
|
||||
"kind": "directory",
|
||||
"size": 0,
|
||||
"file_blake3": null
|
||||
},
|
||||
{
|
||||
"canonical_path": "bin/ggoo-payload.bin",
|
||||
"kind": "file",
|
||||
"size": 2097152,
|
||||
"file_blake3": "0c793d5caa4d76d70ae2251950b74c89fe8d795d9467f8d00a115b254a5483ad"
|
||||
},
|
||||
{
|
||||
"canonical_path": "data",
|
||||
"kind": "directory",
|
||||
"size": 0,
|
||||
"file_blake3": null
|
||||
},
|
||||
{
|
||||
"canonical_path": "data/ggoo-assets.dat",
|
||||
"kind": "file",
|
||||
"size": 1048576,
|
||||
"file_blake3": "e0177aff792b3796037edddd40e4cf834448f638f24225fc891c645619004b35"
|
||||
}
|
||||
],
|
||||
"content_id": "30a6358eff3b0ed6d797aee10766ca69741eb1198f83d341d0876b5ce1009ae0"
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
{"schema_version":1,"games":{"cnctw":{"game_version":"20160128","content_id":"629012cd3c4a2f7bcbc3d1f40bf111381cd08b98ffb571747623e3912b586344","supports_streamed_install":true}}}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"game_id": "cnctw",
|
||||
"game_version": "20160128",
|
||||
"chunk_size": 134217728,
|
||||
"files": [
|
||||
{
|
||||
"canonical_path": "a-first.eti",
|
||||
"kind": "file",
|
||||
"size": 103,
|
||||
"file_blake3": "a67333b9f370ff284a41e8c4af1a74881a46c32fe7bee77c70230c6b0fb53a6e",
|
||||
"chunk_blake3": [
|
||||
"a67333b9f370ff284a41e8c4af1a74881a46c32fe7bee77c70230c6b0fb53a6e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"canonical_path": "version.ini",
|
||||
"kind": "file",
|
||||
"size": 8,
|
||||
"file_blake3": "995b0db832e29dbb6025f31b7aa8f1e842151971fac3ea7d6cac2c287f2ce64a",
|
||||
"chunk_blake3": [
|
||||
"995b0db832e29dbb6025f31b7aa8f1e842151971fac3ea7d6cac2c287f2ce64a"
|
||||
]
|
||||
},
|
||||
{
|
||||
"canonical_path": "z-second.eti",
|
||||
"kind": "file",
|
||||
"size": 105,
|
||||
"file_blake3": "465e3bf1e077a114422a75095271072627b9a9aaf7e72ae7c9661f9ac972ccaf",
|
||||
"chunk_blake3": [
|
||||
"465e3bf1e077a114422a75095271072627b9a9aaf7e72ae7c9661f9ac972ccaf"
|
||||
]
|
||||
}
|
||||
],
|
||||
"streamed_install_files": [
|
||||
{
|
||||
"canonical_path": "order/first.txt",
|
||||
"kind": "file",
|
||||
"size": 22,
|
||||
"file_blake3": "f0440582fb0af9b05a9755bf5764127ecc09a0f57aa267599770cbec189ebb54"
|
||||
},
|
||||
{
|
||||
"canonical_path": "order/second.txt",
|
||||
"kind": "file",
|
||||
"size": 23,
|
||||
"file_blake3": "b931fd872258655d87983bdc7d56bc94d30b31046c4184487121bc3e012330e5"
|
||||
}
|
||||
],
|
||||
"content_id": "629012cd3c4a2f7bcbc3d1f40bf111381cd08b98ffb571747623e3912b586344"
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
{"schema_version":1,"games":{"cnctw":{"game_version":"20160128","content_id":"c572c586174ceb5c40bd15e8b02784b3159464c4f8c87a9b62a4d6ed801be4d5","supports_streamed_install":true}}}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"game_id": "cnctw",
|
||||
"game_version": "20160128",
|
||||
"chunk_size": 134217728,
|
||||
"files": [
|
||||
{
|
||||
"canonical_path": "cnctw.eti",
|
||||
"kind": "file",
|
||||
"size": 311,
|
||||
"file_blake3": "a6e31754a470b0d8001040b2eece106c0becd84d50a18b03faae1bc7a8df4e43",
|
||||
"chunk_blake3": [
|
||||
"a6e31754a470b0d8001040b2eece106c0becd84d50a18b03faae1bc7a8df4e43"
|
||||
]
|
||||
},
|
||||
{
|
||||
"canonical_path": "version.ini",
|
||||
"kind": "file",
|
||||
"size": 8,
|
||||
"file_blake3": "995b0db832e29dbb6025f31b7aa8f1e842151971fac3ea7d6cac2c287f2ce64a",
|
||||
"chunk_blake3": [
|
||||
"995b0db832e29dbb6025f31b7aa8f1e842151971fac3ea7d6cac2c287f2ce64a"
|
||||
]
|
||||
}
|
||||
],
|
||||
"streamed_install_files": [
|
||||
{
|
||||
"canonical_path": "bin",
|
||||
"kind": "directory",
|
||||
"size": 0,
|
||||
"file_blake3": null
|
||||
},
|
||||
{
|
||||
"canonical_path": "bin/cnctw-solid-payload.bin",
|
||||
"kind": "file",
|
||||
"size": 40,
|
||||
"file_blake3": "5ed7b587a848236aa68465d2729358beaec595be9edfaea6a20f66fcb03b9a7c"
|
||||
},
|
||||
{
|
||||
"canonical_path": "data",
|
||||
"kind": "directory",
|
||||
"size": 0,
|
||||
"file_blake3": null
|
||||
},
|
||||
{
|
||||
"canonical_path": "data/cnctw-solid-assets.dat",
|
||||
"kind": "file",
|
||||
"size": 78,
|
||||
"file_blake3": "183868e423478db1e256fd1c3dd3b1b767acb747b4fde1985db56387e418185c"
|
||||
}
|
||||
],
|
||||
"content_id": "c572c586174ceb5c40bd15e8b02784b3159464c4f8c87a9b62a4d6ed801be4d5"
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
{"schema_version":1,"games":{"cod2":{"game_version":"20160922","content_id":"6f60950ccdb7ec12e8e9b68210bb345994490dd5ca761c063ac27a6caf250071","supports_streamed_install":false}}}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"game_id": "cod2",
|
||||
"game_version": "20160922",
|
||||
"chunk_size": 134217728,
|
||||
"files": [
|
||||
{
|
||||
"canonical_path": "catalog-unknown.txt",
|
||||
"kind": "file",
|
||||
"size": 73,
|
||||
"file_blake3": "11bd8035dcf904c547896565bbc8b9ae47b1044ac21ebf732a51820caddfc819",
|
||||
"chunk_blake3": [
|
||||
"11bd8035dcf904c547896565bbc8b9ae47b1044ac21ebf732a51820caddfc819"
|
||||
]
|
||||
},
|
||||
{
|
||||
"canonical_path": "version.ini",
|
||||
"kind": "file",
|
||||
"size": 9,
|
||||
"file_blake3": "91c51abb1c7e7a6426bc9003603608587648bd1fed32b0cf3e81bc5ad76531b4",
|
||||
"chunk_blake3": [
|
||||
"91c51abb1c7e7a6426bc9003603608587648bd1fed32b0cf3e81bc5ad76531b4"
|
||||
]
|
||||
}
|
||||
],
|
||||
"streamed_install_files": [],
|
||||
"content_id": "6f60950ccdb7ec12e8e9b68210bb345994490dd5ca761c063ac27a6caf250071"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
This package is known only to the source peer's fixture catalog profile.
|
||||
@@ -0,0 +1 @@
|
||||
20160922
|
||||
@@ -3,17 +3,27 @@
|
||||
#![allow(clippy::missing_errors_doc)]
|
||||
|
||||
use std::{
|
||||
fmt::Write as _,
|
||||
net::SocketAddr,
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use eyre::{Context, OptionExt};
|
||||
use lanspread_peer::{CallToPlayEvent, UnpackFuture, Unpacker};
|
||||
use lanspread_peer::{
|
||||
CallToPlayLocalIntent,
|
||||
PeerEndpoint,
|
||||
PeerId,
|
||||
ScopedProcess,
|
||||
UnpackFuture,
|
||||
Unpacker,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub const DEFAULT_FIXTURE_VERSION: &str = "20250101";
|
||||
const EXTERNAL_UNRAR_CAPTURE_LIMIT: usize = 64 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CommandEnvelope {
|
||||
@@ -28,7 +38,10 @@ pub enum CliCommand {
|
||||
ListGames,
|
||||
ListCallToPlay,
|
||||
PublishCallToPlay {
|
||||
event: CallToPlayEvent,
|
||||
intent: CallToPlayLocalIntent,
|
||||
},
|
||||
SetCallToPlayDisplayName {
|
||||
display_name: String,
|
||||
},
|
||||
SetGameDir {
|
||||
path: PathBuf,
|
||||
@@ -39,6 +52,9 @@ pub enum CliCommand {
|
||||
},
|
||||
StreamInstall {
|
||||
game_id: String,
|
||||
account_name: Option<String>,
|
||||
language: Option<String>,
|
||||
persona_name: Option<String>,
|
||||
},
|
||||
CancelDownload {
|
||||
game_id: String,
|
||||
@@ -59,7 +75,7 @@ pub enum CliCommand {
|
||||
timeout: Duration,
|
||||
},
|
||||
Connect {
|
||||
addr: SocketAddr,
|
||||
endpoint: PeerEndpoint,
|
||||
},
|
||||
Shutdown,
|
||||
}
|
||||
@@ -73,6 +89,7 @@ impl CliCommand {
|
||||
Self::ListGames => "list-games",
|
||||
Self::ListCallToPlay => "list-call-to-play",
|
||||
Self::PublishCallToPlay { .. } => "publish-call-to-play",
|
||||
Self::SetCallToPlayDisplayName { .. } => "set-call-to-play-display-name",
|
||||
Self::SetGameDir { .. } => "set-game-dir",
|
||||
Self::Download { .. } => "download",
|
||||
Self::StreamInstall { .. } => "stream-install",
|
||||
@@ -110,13 +127,16 @@ pub fn parse_command_value(value: &Value) -> eyre::Result<CommandEnvelope> {
|
||||
"list-games" => CliCommand::ListGames,
|
||||
"list-call-to-play" => CliCommand::ListCallToPlay,
|
||||
"publish-call-to-play" => CliCommand::PublishCallToPlay {
|
||||
event: serde_json::from_value(
|
||||
intent: serde_json::from_value(
|
||||
object
|
||||
.get("event")
|
||||
.get("intent")
|
||||
.cloned()
|
||||
.ok_or_eyre("publish-call-to-play must include event")?,
|
||||
.ok_or_eyre("publish-call-to-play must include intent")?,
|
||||
)
|
||||
.wrap_err("invalid Call to Play event")?,
|
||||
.wrap_err("invalid Call to Play intent")?,
|
||||
},
|
||||
"set-call-to-play-display-name" => CliCommand::SetCallToPlayDisplayName {
|
||||
display_name: required_str(object, "display_name")?,
|
||||
},
|
||||
"set-game-dir" => CliCommand::SetGameDir {
|
||||
path: PathBuf::from(required_str(object, "path")?),
|
||||
@@ -127,6 +147,9 @@ pub fn parse_command_value(value: &Value) -> eyre::Result<CommandEnvelope> {
|
||||
},
|
||||
"stream-install" => CliCommand::StreamInstall {
|
||||
game_id: game_id(object)?,
|
||||
account_name: optional_str(object, "account_name")?,
|
||||
language: optional_str(object, "language")?,
|
||||
persona_name: optional_str(object, "persona_name")?,
|
||||
},
|
||||
"cancel-download" => CliCommand::CancelDownload {
|
||||
game_id: game_id(object)?,
|
||||
@@ -151,11 +174,17 @@ pub fn parse_command_value(value: &Value) -> eyre::Result<CommandEnvelope> {
|
||||
.wrap_err("count does not fit in usize")?,
|
||||
timeout: Duration::from_millis(required_u64(object, "timeout_ms")?),
|
||||
},
|
||||
"connect" | "direct-connect" => CliCommand::Connect {
|
||||
addr: required_str(object, "addr")?
|
||||
.parse()
|
||||
.wrap_err("addr must be a socket address like 127.0.0.1:12345")?,
|
||||
},
|
||||
"connect" | "direct-connect" => {
|
||||
let peer_id = required_str(object, "peer_id")?
|
||||
.parse::<PeerId>()
|
||||
.wrap_err("peer_id is not a canonical peer ID")?;
|
||||
let addr = required_str(object, "addr")?
|
||||
.parse::<SocketAddr>()
|
||||
.wrap_err("addr must be a socket address like 127.0.0.1:12345")?;
|
||||
CliCommand::Connect {
|
||||
endpoint: PeerEndpoint::new(peer_id, addr),
|
||||
}
|
||||
}
|
||||
"shutdown" => CliCommand::Shutdown,
|
||||
other => eyre::bail!("unknown command: {other}"),
|
||||
};
|
||||
@@ -177,6 +206,21 @@ fn required_str(
|
||||
.ok_or_else(|| eyre::eyre!("missing string field {field}"))
|
||||
}
|
||||
|
||||
fn optional_str(
|
||||
object: &serde_json::Map<String, Value>,
|
||||
field: &'static str,
|
||||
) -> eyre::Result<Option<String>> {
|
||||
object
|
||||
.get(field)
|
||||
.map(|value| {
|
||||
value
|
||||
.as_str()
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| eyre::eyre!("{field} must be a string when provided"))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn required_u64(object: &serde_json::Map<String, Value>, field: &'static str) -> eyre::Result<u64> {
|
||||
object
|
||||
.get(field)
|
||||
@@ -241,11 +285,28 @@ pub fn seed_fixture_game(game_dir: &Path, fixture_name: &str) -> eyre::Result<Fi
|
||||
pub struct FixtureUnpacker;
|
||||
|
||||
impl Unpacker for FixtureUnpacker {
|
||||
fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
|
||||
fn unpack<'a>(
|
||||
&'a self,
|
||||
archive: &'a Path,
|
||||
dest: &'a Path,
|
||||
cancel_token: CancellationToken,
|
||||
) -> UnpackFuture<'a> {
|
||||
Box::pin(async move {
|
||||
tokio::fs::create_dir_all(dest).await?;
|
||||
let payload = tokio::fs::read(archive).await?;
|
||||
tokio::fs::write(dest.join("fixture-payload.txt"), payload).await?;
|
||||
if cancel_token.is_cancelled() {
|
||||
eyre::bail!("fixture extraction for {} was cancelled", archive.display());
|
||||
}
|
||||
// These fixture files are deliberately tiny. Keep each operation
|
||||
// lexical: selecting against Tokio's filesystem helpers could drop
|
||||
// their futures while blocking-pool work still touches staging.
|
||||
std::fs::create_dir_all(dest)?;
|
||||
if cancel_token.is_cancelled() {
|
||||
eyre::bail!("fixture extraction for {} was cancelled", archive.display());
|
||||
}
|
||||
let payload = std::fs::read(archive)?;
|
||||
if cancel_token.is_cancelled() {
|
||||
eyre::bail!("fixture extraction for {} was cancelled", archive.display());
|
||||
}
|
||||
std::fs::write(dest.join("fixture-payload.txt"), payload)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -263,20 +324,46 @@ impl ExternalUnrarUnpacker {
|
||||
}
|
||||
|
||||
impl Unpacker for ExternalUnrarUnpacker {
|
||||
fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
|
||||
fn unpack<'a>(
|
||||
&'a self,
|
||||
archive: &'a Path,
|
||||
dest: &'a Path,
|
||||
cancel_token: CancellationToken,
|
||||
) -> UnpackFuture<'a> {
|
||||
Box::pin(async move {
|
||||
tokio::fs::create_dir_all(dest).await?;
|
||||
let status = tokio::process::Command::new(&self.program)
|
||||
.arg("x")
|
||||
.arg("-o+")
|
||||
.arg(archive)
|
||||
.arg(dest)
|
||||
.status()
|
||||
.await?;
|
||||
if !status.success() {
|
||||
if cancel_token.is_cancelled() {
|
||||
eyre::bail!("unrar extraction for {} was cancelled", archive.display());
|
||||
}
|
||||
// Directory creation is small and synchronous; keeping it lexical
|
||||
// prevents a dropped Tokio fs future from outliving rollback.
|
||||
std::fs::create_dir_all(dest)?;
|
||||
if cancel_token.is_cancelled() {
|
||||
eyre::bail!("unrar extraction for {} was cancelled", archive.display());
|
||||
}
|
||||
|
||||
let process = ScopedProcess::spawn(
|
||||
&self.program,
|
||||
[
|
||||
std::ffi::OsString::from("x"),
|
||||
std::ffi::OsString::from("-o+"),
|
||||
std::ffi::OsString::from("-p-"),
|
||||
archive.as_os_str().to_owned(),
|
||||
dest.as_os_str().to_owned(),
|
||||
],
|
||||
&cancel_token,
|
||||
EXTERNAL_UNRAR_CAPTURE_LIMIT,
|
||||
)?;
|
||||
let output = process.wait().await?;
|
||||
if !output.status.success() {
|
||||
eyre::bail!(
|
||||
"unrar failed for {} with status {status}",
|
||||
archive.display()
|
||||
"unrar failed for {} with status {}: {}",
|
||||
archive.display(),
|
||||
output.status,
|
||||
format_captured_process_output(
|
||||
&output.stderr,
|
||||
output.stderr_truncated,
|
||||
"stderr"
|
||||
)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
@@ -284,6 +371,14 @@ impl Unpacker for ExternalUnrarUnpacker {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_captured_process_output(bytes: &[u8], truncated: bool, stream: &str) -> String {
|
||||
let mut output = String::from_utf8_lossy(bytes).into_owned();
|
||||
if truncated {
|
||||
let _ = write!(output, "\n[{stream} truncated]");
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
pub fn result_line(id: &Option<Value>, command: &str, data: Value) -> eyre::Result<String> {
|
||||
output_line(json!({
|
||||
"type": "result",
|
||||
@@ -317,6 +412,8 @@ fn output_line(value: Value) -> eyre::Result<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
@@ -374,6 +471,14 @@ mod tests {
|
||||
assert_eq!(parsed["data"]["peer_count"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_process_output_marks_truncation() {
|
||||
assert_eq!(
|
||||
format_captured_process_output(b"partial", true, "stderr"),
|
||||
"partial\n[stderr truncated]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_stream_install_command() {
|
||||
let parsed = parse_command_line(r#"{"cmd":"stream-install","game_id":"cnctw"}"#)
|
||||
@@ -383,6 +488,27 @@ mod tests {
|
||||
parsed.command,
|
||||
CliCommand::StreamInstall {
|
||||
game_id: "cnctw".to_string(),
|
||||
account_name: None,
|
||||
language: None,
|
||||
persona_name: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_optional_stream_install_settings_without_sanitizing_them() {
|
||||
let parsed = parse_command_line(
|
||||
r#"{"cmd":"stream-install","game_id":"cnctw","account_name":" Alice% ","language":"DE","persona_name":" Player% "}"#,
|
||||
)
|
||||
.expect("command should parse");
|
||||
|
||||
assert_eq!(
|
||||
parsed.command,
|
||||
CliCommand::StreamInstall {
|
||||
game_id: "cnctw".to_string(),
|
||||
account_name: Some(" Alice% ".to_string()),
|
||||
language: Some("DE".to_string()),
|
||||
persona_name: Some(" Player% ".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -401,18 +527,103 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_call_to_play_event_command() {
|
||||
fn parses_connect_as_an_authenticated_peer_endpoint() {
|
||||
let parsed = parse_command_line(
|
||||
r#"{"cmd":"publish-call-to-play","event":{"id":"event-1","call_id":"call-1","actor_id":"","actor_name":"Alice","at":1000,"action":{"Create":{"game_id":"game-1","max_players":4,"scheduled_for":null,"deadline":61000}}}}"#,
|
||||
r#"{"cmd":"connect","peer_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","addr":"127.0.0.1:34567"}"#,
|
||||
)
|
||||
.expect("authenticated connect command should parse");
|
||||
|
||||
assert_eq!(
|
||||
parsed.command,
|
||||
CliCommand::Connect {
|
||||
endpoint: PeerEndpoint::new(
|
||||
PeerId::from_bytes([0; 32]),
|
||||
"127.0.0.1:34567"
|
||||
.parse()
|
||||
.expect("test socket address should parse"),
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_address_only_connect() {
|
||||
let error = parse_command_line(r#"{"cmd":"connect","addr":"127.0.0.1:34567"}"#)
|
||||
.expect_err("connect must require an authenticated peer identity");
|
||||
|
||||
assert!(error.to_string().contains("missing string field peer_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_noncanonical_connect_peer_id() {
|
||||
let error = parse_command_line(
|
||||
r#"{"cmd":"connect","peer_id":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","addr":"127.0.0.1:34567"}"#,
|
||||
)
|
||||
.expect_err("connect must reject noncanonical peer IDs");
|
||||
|
||||
assert!(error.to_string().contains("canonical peer ID"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_intent_only_call_to_play_command() {
|
||||
let parsed = parse_command_line(
|
||||
r#"{"cmd":"publish-call-to-play","intent":{"call_id":null,"action":{"Create":{"game_id":"game-1","max_players":4,"scheduled_for":null,"deadline":61000}}}}"#,
|
||||
)
|
||||
.expect("command should parse");
|
||||
|
||||
let CliCommand::PublishCallToPlay { event } = parsed.command else {
|
||||
let CliCommand::PublishCallToPlay { intent } = parsed.command else {
|
||||
panic!("expected PublishCallToPlay");
|
||||
};
|
||||
assert_eq!(event.id, "event-1");
|
||||
assert_eq!(event.call_id, "call-1");
|
||||
assert_eq!(event.actor_name, "Alice");
|
||||
assert!(intent.call_id.is_none());
|
||||
assert!(matches!(
|
||||
intent.action,
|
||||
lanspread_peer::CallToPlayLocalAction::Create {
|
||||
game_id,
|
||||
max_players: 4,
|
||||
scheduled_for: None,
|
||||
deadline: 61_000,
|
||||
} if game_id == "game-1"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_existing_call_rsvp_and_chat_intents_used_by_late_join_scenarios() {
|
||||
let call_id = format!("{}.{}", "a".repeat(52), "ab".repeat(16));
|
||||
let rsvp = parse_command_line(&format!(
|
||||
r#"{{"cmd":"publish-call-to-play","intent":{{"call_id":"{call_id}","action":"Rsvp"}}}}"#,
|
||||
))
|
||||
.expect("RSVP intent should parse");
|
||||
let chat = parse_command_line(&format!(
|
||||
r#"{{"cmd":"publish-call-to-play","intent":{{"call_id":"{call_id}","action":{{"SendMessage":{{"text":"I am in"}}}}}}}}"#,
|
||||
))
|
||||
.expect("chat intent should parse");
|
||||
|
||||
let CliCommand::PublishCallToPlay { intent: rsvp } = rsvp.command else {
|
||||
panic!("expected RSVP PublishCallToPlay");
|
||||
};
|
||||
let CliCommand::PublishCallToPlay { intent: chat } = chat.command else {
|
||||
panic!("expected chat PublishCallToPlay");
|
||||
};
|
||||
assert_eq!(rsvp.call_id, chat.call_id);
|
||||
assert!(matches!(
|
||||
rsvp.action,
|
||||
lanspread_peer::CallToPlayLocalAction::Rsvp
|
||||
));
|
||||
assert!(matches!(
|
||||
chat.action,
|
||||
lanspread_peer::CallToPlayLocalAction::SendMessage { text }
|
||||
if text == "I am in"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_client_authored_call_to_play_event_fields() {
|
||||
let error = parse_command_line(
|
||||
r#"{"cmd":"publish-call-to-play","intent":{"call_id":null,"id":"event-1","at":1000,"action":{"Rsvp":null}}}"#,
|
||||
)
|
||||
.expect_err("client-authored event identity and time must be rejected");
|
||||
|
||||
assert!(error.to_string().contains("invalid Call to Play intent"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -421,7 +632,7 @@ mod tests {
|
||||
let seed = seed_fixture_game(temp.path(), "fixture-one").expect("fixture should seed");
|
||||
let dest = temp.path().join("staging");
|
||||
Arc::new(FixtureUnpacker)
|
||||
.unpack(&seed.archive, &dest)
|
||||
.unpack(&seed.archive, &dest, CancellationToken::new())
|
||||
.await
|
||||
.expect("fixture archive should unpack");
|
||||
|
||||
@@ -429,4 +640,126 @@ mod tests {
|
||||
.expect("payload should be written");
|
||||
assert!(payload.contains("fixture-one"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn controlled_unrar(temp: &TempDir) -> (Arc<ExternalUnrarUnpacker>, PathBuf, PathBuf) {
|
||||
let program = temp.path().join("controlled-unrar");
|
||||
std::fs::write(
|
||||
&program,
|
||||
r#"#!/bin/sh
|
||||
set -eu
|
||||
[ "$3" = "-p-" ]
|
||||
dest=$5
|
||||
printf '%s' "$$" > "$dest/child.pid"
|
||||
printf 'started' > "$dest/started"
|
||||
while [ ! -e "$dest/release" ]; do :; done
|
||||
printf 'late' > "$dest/late-canary"
|
||||
"#,
|
||||
)
|
||||
.expect("controlled unrar should be written");
|
||||
let mut permissions = std::fs::metadata(&program)
|
||||
.expect("controlled unrar metadata should be readable")
|
||||
.permissions();
|
||||
permissions.set_mode(0o700);
|
||||
std::fs::set_permissions(&program, permissions)
|
||||
.expect("controlled unrar should be executable");
|
||||
|
||||
let archive = temp.path().join("archive.eti");
|
||||
std::fs::write(&archive, b"fixture").expect("archive should be written");
|
||||
let destination = temp.path().join("destination");
|
||||
(
|
||||
Arc::new(ExternalUnrarUnpacker::new(program)),
|
||||
archive,
|
||||
destination,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn wait_for_path(path: &Path) {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
||||
while !path.exists() {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("controlled child marker should appear");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn wait_for_process_exit(pid: u32) {
|
||||
let process = PathBuf::from(format!("/proc/{pid}"));
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
||||
while process.exists() {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("controlled child should no longer exist");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn controlled_child_pid(destination: &Path) -> u32 {
|
||||
std::fs::read_to_string(destination.join("child.pid"))
|
||||
.expect("controlled child pid should be readable")
|
||||
.parse()
|
||||
.expect("controlled child pid should be numeric")
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn external_unrar_cancellation_kills_and_reaps_before_return() {
|
||||
let temp = TempDir::new("lanspread-peer-cli-unrar-cancel");
|
||||
let (unpacker, archive, destination) = controlled_unrar(&temp);
|
||||
let cancel_token = CancellationToken::new();
|
||||
let task_cancel_token = cancel_token.clone();
|
||||
let task_destination = destination.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
unpacker
|
||||
.unpack(&archive, &task_destination, task_cancel_token)
|
||||
.await
|
||||
});
|
||||
|
||||
wait_for_path(&destination.join("started")).await;
|
||||
#[cfg(target_os = "linux")]
|
||||
let pid = controlled_child_pid(&destination);
|
||||
cancel_token.cancel();
|
||||
let error = tokio::time::timeout(std::time::Duration::from_secs(2), task)
|
||||
.await
|
||||
.expect("cancelled unpack should settle")
|
||||
.expect("unpack task should not panic")
|
||||
.expect_err("cancelled unpack should fail");
|
||||
assert!(error.to_string().contains("cancelled"));
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
wait_for_process_exit(pid).await;
|
||||
std::fs::write(destination.join("release"), b"").expect("release marker should be written");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
||||
assert!(!destination.join("late-canary").exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn dropping_external_unrar_future_kills_child() {
|
||||
let temp = TempDir::new("lanspread-peer-cli-unrar-drop");
|
||||
let (unpacker, archive, destination) = controlled_unrar(&temp);
|
||||
let task_destination = destination.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
unpacker
|
||||
.unpack(&archive, &task_destination, CancellationToken::new())
|
||||
.await
|
||||
});
|
||||
|
||||
wait_for_path(&destination.join("started")).await;
|
||||
#[cfg(target_os = "linux")]
|
||||
let pid = controlled_child_pid(&destination);
|
||||
task.abort();
|
||||
task.await
|
||||
.expect_err("aborted unpack task should be cancelled");
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
wait_for_process_exit(pid).await;
|
||||
std::fs::write(destination.join("release"), b"").expect("release marker should be written");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
||||
assert!(!destination.join("late-canary").exists());
|
||||
}
|
||||
}
|
||||
|
||||
+1608
-268
@@ -1,33 +1,48 @@
|
||||
//! JSONL command-line harness for running a peer without the Tauri GUI.
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::io::{Cursor, Read as _};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
collections::HashMap,
|
||||
ffi::OsString,
|
||||
io::Write as _,
|
||||
future::Future,
|
||||
io::{self, Write as _},
|
||||
net::SocketAddr,
|
||||
path::{Path, PathBuf},
|
||||
pin::Pin,
|
||||
sync::{Arc, Mutex},
|
||||
task::{Context as TaskContext, Poll},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use eyre::Context;
|
||||
use lanspread_compat::eti::get_games;
|
||||
use lanspread_db::db::{Game, GameCatalog, GameFileDescription};
|
||||
use lanspread_compat::catalog_bundle::load_catalog_bundle;
|
||||
use lanspread_db::{
|
||||
content_manifest::CatalogBundle,
|
||||
db::{Game, GameDB},
|
||||
};
|
||||
use lanspread_peer::{
|
||||
ActiveOperation,
|
||||
ActiveOperationKind,
|
||||
CallToPlayEvent,
|
||||
CallToPlayView,
|
||||
DownloadAttemptId,
|
||||
DownloadAttemptKey,
|
||||
DownloadFailureReason,
|
||||
ExternalUnrarStreamProvider,
|
||||
NoopStreamInstallProvider,
|
||||
OutboundTransfers,
|
||||
PeerCommand,
|
||||
PeerEndpoint,
|
||||
PeerEvent,
|
||||
PeerGameDB,
|
||||
PeerIdentity,
|
||||
PeerRuntimeComponent,
|
||||
PeerRuntimeHandle,
|
||||
PeerSnapshot,
|
||||
PeerStartOptions,
|
||||
RemoteLibraryView,
|
||||
StreamInstallProvider,
|
||||
StreamInstallSettings,
|
||||
load_peer_identity,
|
||||
migrate_legacy_state,
|
||||
start_peer_with_options,
|
||||
};
|
||||
@@ -46,8 +61,9 @@ use lanspread_peer_cli::{
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, BufReader},
|
||||
io::{AsyncBufReadExt, AsyncRead, BufReader, ReadBuf},
|
||||
sync::{Notify, RwLock, mpsc, oneshot},
|
||||
task::{JoinError, JoinHandle},
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -55,7 +71,9 @@ struct Args {
|
||||
name: String,
|
||||
games_dir: PathBuf,
|
||||
state_dir: PathBuf,
|
||||
catalog_db: Option<PathBuf>,
|
||||
identity_file: Option<PathBuf>,
|
||||
catalog_db: PathBuf,
|
||||
manifests_dir: PathBuf,
|
||||
fixtures: Vec<String>,
|
||||
unrar: Option<PathBuf>,
|
||||
}
|
||||
@@ -99,10 +117,8 @@ struct CliState {
|
||||
local_games: Vec<Game>,
|
||||
remote_games: Vec<Game>,
|
||||
active_operations: Vec<ActiveOperation>,
|
||||
game_files: HashMap<String, Vec<GameFileDescription>>,
|
||||
unavailable_games: HashSet<String>,
|
||||
downloads: HashMap<String, DownloadMeasurement>,
|
||||
call_to_play_events: Vec<CallToPlayEvent>,
|
||||
call_to_play_view: CallToPlayView,
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
@@ -112,6 +128,7 @@ struct LocalPeer {
|
||||
}
|
||||
|
||||
struct DownloadMeasurement {
|
||||
attempt_id: DownloadAttemptId,
|
||||
started_at: Instant,
|
||||
bytes: u64,
|
||||
chunks: u64,
|
||||
@@ -120,26 +137,180 @@ struct DownloadMeasurement {
|
||||
struct SharedState {
|
||||
state: RwLock<CliState>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
catalog_game_db: Arc<GameDB>,
|
||||
catalog_bundle: Option<Arc<CatalogBundle>>,
|
||||
call_to_play_display_name: RwLock<String>,
|
||||
active_outbound_transfers: OutboundTransfers,
|
||||
notify: Notify,
|
||||
games_dir: PathBuf,
|
||||
games_dir: RwLock<PathBuf>,
|
||||
state_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum CommandLoopExit {
|
||||
Eof,
|
||||
Shutdown { request_id: Option<Value> },
|
||||
Signal,
|
||||
}
|
||||
|
||||
enum CommandInput {
|
||||
#[cfg(unix)]
|
||||
Pollable(UnixStdin),
|
||||
#[cfg(unix)]
|
||||
Buffered(Cursor<Vec<u8>>),
|
||||
#[cfg(not(unix))]
|
||||
Blocking(tokio::io::Stdin),
|
||||
}
|
||||
|
||||
impl CommandInput {
|
||||
#[cfg(unix)]
|
||||
fn open() -> io::Result<Self> {
|
||||
let duplicate = rustix::io::dup(std::io::stdin()).map_err(io::Error::from)?;
|
||||
Self::from_unix_file(std::fs::File::from(duplicate))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn from_unix_file(mut file: std::fs::File) -> io::Result<Self> {
|
||||
// Tokio reactors do not support ordinary files on every Unix. Read a
|
||||
// finite redirected command file before the peer runtime owns any
|
||||
// resources; a pipe or terminal remains incremental and pollable.
|
||||
if file.metadata()?.is_file() {
|
||||
let mut contents = Vec::new();
|
||||
file.read_to_end(&mut contents)?;
|
||||
return Ok(Self::Buffered(Cursor::new(contents)));
|
||||
}
|
||||
|
||||
UnixStdin::new(file).map(Self::Pollable)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[allow(
|
||||
clippy::unnecessary_wraps,
|
||||
reason = "keeps platform-specific stdin construction behind one fallible API"
|
||||
)]
|
||||
fn open() -> io::Result<Self> {
|
||||
Ok(Self::Blocking(tokio::io::stdin()))
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for CommandInput {
|
||||
fn poll_read(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut TaskContext<'_>,
|
||||
buffer: &mut ReadBuf<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
match self.get_mut() {
|
||||
#[cfg(unix)]
|
||||
Self::Pollable(stdin) => Pin::new(stdin).poll_read(cx, buffer),
|
||||
#[cfg(unix)]
|
||||
Self::Buffered(cursor) => Pin::new(cursor).poll_read(cx, buffer),
|
||||
#[cfg(not(unix))]
|
||||
Self::Blocking(stdin) => Pin::new(stdin).poll_read(cx, buffer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
struct UnixStdin {
|
||||
inner: tokio::io::unix::AsyncFd<std::fs::File>,
|
||||
#[cfg(test)]
|
||||
poll_started: Option<Arc<Notify>>,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl UnixStdin {
|
||||
fn new(file: std::fs::File) -> io::Result<Self> {
|
||||
use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl};
|
||||
|
||||
let flags = fcntl_getfl(&file).map_err(io::Error::from)?;
|
||||
fcntl_setfl(&file, flags | OFlags::NONBLOCK).map_err(io::Error::from)?;
|
||||
let inner = tokio::io::unix::AsyncFd::with_interest(file, tokio::io::Interest::READABLE)?;
|
||||
Ok(Self {
|
||||
inner,
|
||||
#[cfg(test)]
|
||||
poll_started: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn with_poll_started(mut self, poll_started: Arc<Notify>) -> Self {
|
||||
self.poll_started = Some(poll_started);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl AsyncRead for UnixStdin {
|
||||
fn poll_read(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut TaskContext<'_>,
|
||||
buffer: &mut ReadBuf<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
if buffer.remaining() == 0 {
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
if let Some(poll_started) = &self.poll_started {
|
||||
poll_started.notify_one();
|
||||
}
|
||||
|
||||
loop {
|
||||
let mut ready = std::task::ready!(self.inner.poll_read_ready_mut(cx))?;
|
||||
match ready.try_io(|inner| {
|
||||
rustix::io::read(inner.get_ref(), buffer.initialize_unfilled())
|
||||
.map_err(io::Error::from)
|
||||
}) {
|
||||
Ok(Ok(read)) => {
|
||||
buffer.advance(read);
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
Ok(Err(error)) if error.kind() == io::ErrorKind::Interrupted => {}
|
||||
Ok(Err(error)) => return Poll::Ready(Err(error)),
|
||||
Err(_would_block) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum CancelledInputExit {
|
||||
Signal,
|
||||
Failure(eyre::Report),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum CliScopeOutcome {
|
||||
Command(CommandLoopExit),
|
||||
CancelledInput(CancelledInputExit),
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
const PLATFORM_INPUT_CANCELLATION_IS_QUIESCENT: bool = true;
|
||||
|
||||
// Tokio's non-Unix stdin uses an uncancellable blocking worker. Once its
|
||||
// command future has been aborted, process termination is the only ownership
|
||||
// boundary that cannot hang while dropping the runtime.
|
||||
#[cfg(not(unix))]
|
||||
const PLATFORM_INPUT_CANCELLATION_IS_QUIESCENT: bool = false;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> eyre::Result<()> {
|
||||
let args = parse_args()?;
|
||||
tokio::fs::create_dir_all(&args.games_dir).await?;
|
||||
tokio::fs::create_dir_all(&args.state_dir).await?;
|
||||
let explicit_identity = load_explicit_identity(args.identity_file.as_deref())?;
|
||||
std::fs::create_dir_all(&args.games_dir)?;
|
||||
std::fs::create_dir_all(&args.state_dir)?;
|
||||
let command_input =
|
||||
CommandInput::open().wrap_err("failed to prepare an interruptible stdin command source")?;
|
||||
|
||||
let (catalog_game_db, catalog_bundle) =
|
||||
load_catalog_authority(&args.catalog_db, &args.manifests_dir, &args.fixtures).await?;
|
||||
let fixture_seeds = seed_fixtures(&args.games_dir, &args.fixtures)?;
|
||||
let catalog = load_catalog(args.catalog_db.as_deref(), &fixture_seeds).await;
|
||||
let migration = migrate_legacy_state(&args.games_dir, &args.state_dir).await;
|
||||
|
||||
let (tx_events, rx_events) = mpsc::unbounded_channel();
|
||||
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
|
||||
let catalog = Arc::new(RwLock::new(catalog));
|
||||
let catalog_game_db = Arc::new(catalog_game_db);
|
||||
let active_outbound_transfers: OutboundTransfers = Arc::new(RwLock::new(HashMap::new()));
|
||||
let unrar_for_streaming = args.unrar.clone().or_else(default_unrar_program);
|
||||
let unpacker: Arc<dyn lanspread_peer::Unpacker> = match args.unrar.clone() {
|
||||
@@ -151,55 +322,97 @@ async fn main() -> eyre::Result<()> {
|
||||
None => Arc::new(NoopStreamInstallProvider),
|
||||
};
|
||||
|
||||
let mut handle = start_peer_with_options(
|
||||
let handle = start_peer_with_options(
|
||||
args.games_dir.clone(),
|
||||
tx_events,
|
||||
peer_game_db.clone(),
|
||||
unpacker,
|
||||
catalog.clone(),
|
||||
Arc::clone(&catalog_bundle),
|
||||
PeerStartOptions {
|
||||
state_dir: Some(args.state_dir.clone()),
|
||||
identity: explicit_identity,
|
||||
active_outbound_transfers: Some(active_outbound_transfers.clone()),
|
||||
stream_install_provider: Some(stream_install_provider),
|
||||
local_network_sharing: true,
|
||||
},
|
||||
)?;
|
||||
let sender = handle.sender();
|
||||
let accepted_game_dir = handle.accepted_game_dir().to_path_buf();
|
||||
|
||||
let shared = Arc::new(SharedState {
|
||||
state: RwLock::new(CliState::default()),
|
||||
peer_game_db,
|
||||
catalog: catalog.clone(),
|
||||
catalog_game_db,
|
||||
catalog_bundle: Some(catalog_bundle),
|
||||
call_to_play_display_name: RwLock::new(args.name.clone()),
|
||||
active_outbound_transfers,
|
||||
notify: Notify::new(),
|
||||
games_dir: args.games_dir.clone(),
|
||||
games_dir: RwLock::new(accepted_game_dir.clone()),
|
||||
state_dir: args.state_dir.clone(),
|
||||
});
|
||||
let writer = JsonlWriter::new();
|
||||
|
||||
tokio::spawn(event_loop(rx_events, shared.clone(), writer.clone()));
|
||||
|
||||
writer.emit(event_line(
|
||||
"cli-started",
|
||||
json!({
|
||||
"name": args.name,
|
||||
"games_dir": args.games_dir,
|
||||
"games_dir": accepted_game_dir,
|
||||
"state_dir": args.state_dir,
|
||||
"catalog_db": args.catalog_db,
|
||||
"manifests_dir": args.manifests_dir,
|
||||
"migration": migration,
|
||||
"fixtures": fixture_seeds,
|
||||
}),
|
||||
));
|
||||
|
||||
command_loop(&sender, &mut handle, shared, writer).await
|
||||
let command_task = tokio::spawn(command_loop(
|
||||
command_input,
|
||||
sender,
|
||||
shared.clone(),
|
||||
writer.clone(),
|
||||
));
|
||||
let event_task = tokio::spawn(event_loop(rx_events, shared, writer.clone()));
|
||||
let shutdown = async move {
|
||||
let mut handle = handle;
|
||||
handle.shutdown();
|
||||
handle.wait_stopped().await;
|
||||
};
|
||||
let outcome = join_cli_scope(command_task, event_task, shutdown_signal(), shutdown).await?;
|
||||
let exit = finish_cli_scope(
|
||||
outcome,
|
||||
PLATFORM_INPUT_CANCELLATION_IS_QUIESCENT,
|
||||
|exit_code, failure| {
|
||||
if let Some(failure) = failure {
|
||||
eprintln!("peer CLI stopped exceptionally: {failure:#}");
|
||||
}
|
||||
std::process::exit(exit_code);
|
||||
},
|
||||
)?;
|
||||
|
||||
if let CommandLoopExit::Shutdown { request_id } = exit {
|
||||
writer.emit(result_line(
|
||||
&request_id,
|
||||
"shutdown",
|
||||
json!({"stopped": true}),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_explicit_identity(path: Option<&Path>) -> eyre::Result<Option<Arc<PeerIdentity>>> {
|
||||
path.map(load_peer_identity)
|
||||
.transpose()
|
||||
.wrap_err("failed to load explicitly selected peer identity")
|
||||
.map(|identity| identity.map(Arc::new))
|
||||
}
|
||||
|
||||
async fn command_loop(
|
||||
sender: &mpsc::UnboundedSender<PeerCommand>,
|
||||
handle: &mut PeerRuntimeHandle,
|
||||
input: CommandInput,
|
||||
sender: mpsc::UnboundedSender<PeerCommand>,
|
||||
shared: Arc<SharedState>,
|
||||
writer: JsonlWriter,
|
||||
) -> eyre::Result<()> {
|
||||
let stdin = BufReader::new(tokio::io::stdin());
|
||||
let mut lines = stdin.lines();
|
||||
) -> eyre::Result<CommandLoopExit> {
|
||||
let mut lines = BufReader::new(input).lines();
|
||||
|
||||
while let Some(line) = lines.next_line().await? {
|
||||
if line.trim().is_empty() {
|
||||
@@ -214,13 +427,16 @@ async fn command_loop(
|
||||
}
|
||||
};
|
||||
|
||||
if matches!(&envelope.command, CliCommand::Shutdown) {
|
||||
return Ok(CommandLoopExit::Shutdown {
|
||||
request_id: envelope.request_id,
|
||||
});
|
||||
}
|
||||
|
||||
let command_name = envelope.command.name();
|
||||
match handle_command(&envelope, sender, handle, &shared).await {
|
||||
match handle_command(&envelope, &sender, &shared).await {
|
||||
Ok(data) => {
|
||||
writer.emit(result_line(&envelope.request_id, command_name, data));
|
||||
if matches!(envelope.command, CliCommand::Shutdown) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
writer.emit(error_line(
|
||||
@@ -232,13 +448,13 @@ async fn command_loop(
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok(CommandLoopExit::Eof)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn handle_command(
|
||||
envelope: &CommandEnvelope,
|
||||
sender: &mpsc::UnboundedSender<PeerCommand>,
|
||||
handle: &mut PeerRuntimeHandle,
|
||||
shared: &Arc<SharedState>,
|
||||
) -> eyre::Result<Value> {
|
||||
match &envelope.command {
|
||||
@@ -247,59 +463,80 @@ async fn handle_command(
|
||||
CliCommand::ListGames => list_games(shared).await,
|
||||
CliCommand::ListCallToPlay => {
|
||||
let (reply, result) = oneshot::channel();
|
||||
sender.send(PeerCommand::GetCallToPlayEvents { reply: Some(reply) })?;
|
||||
let events = tokio::time::timeout(Duration::from_secs(1), result)
|
||||
sender.send(PeerCommand::GetCallToPlayView { reply: Some(reply) })?;
|
||||
let view = tokio::time::timeout(Duration::from_secs(1), result)
|
||||
.await
|
||||
.wrap_err("timed out waiting for Call to Play history")?
|
||||
.wrap_err("peer stopped before returning Call to Play history")?;
|
||||
Ok(json!({ "events": events }))
|
||||
.wrap_err("timed out waiting for Call to Play view")?
|
||||
.wrap_err("peer stopped before returning Call to Play view")?
|
||||
.map_err(eyre::Report::msg)?;
|
||||
Ok(json!({ "view": view }))
|
||||
}
|
||||
CliCommand::PublishCallToPlay { event } => {
|
||||
CliCommand::PublishCallToPlay { intent } => {
|
||||
let (reply, result) = oneshot::channel();
|
||||
sender.send(PeerCommand::PublishCallToPlay {
|
||||
event: event.clone(),
|
||||
sender.send(PeerCommand::ApplyCallToPlayIntent {
|
||||
intent: intent.clone(),
|
||||
display_name: shared.call_to_play_display_name.read().await.clone(),
|
||||
reply,
|
||||
})?;
|
||||
result
|
||||
let receipt = result
|
||||
.await
|
||||
.wrap_err("peer stopped before publishing Call to Play event")?
|
||||
.wrap_err("peer stopped before applying Call to Play intent")?
|
||||
.map_err(eyre::Report::msg)?;
|
||||
Ok(json!({"published": true, "event_id": event.id}))
|
||||
Ok(json!({"published": true, "receipt": receipt}))
|
||||
}
|
||||
CliCommand::SetGameDir { path } => {
|
||||
sender.send(PeerCommand::SetGameDir(path.clone()))?;
|
||||
Ok(json!({"queued": true, "path": path}))
|
||||
CliCommand::SetCallToPlayDisplayName { display_name } => {
|
||||
let (reply, result) = oneshot::channel();
|
||||
sender.send(PeerCommand::SetCallToPlayDisplayName {
|
||||
display_name: display_name.clone(),
|
||||
reply,
|
||||
})?;
|
||||
let changed = result
|
||||
.await
|
||||
.wrap_err("peer stopped before updating the Call to Play display name")?
|
||||
.map_err(eyre::Report::msg)?;
|
||||
*shared.call_to_play_display_name.write().await = display_name.clone();
|
||||
Ok(json!({"changed": changed, "display_name": display_name}))
|
||||
}
|
||||
CliCommand::SetGameDir { path } => set_game_dir(sender, shared, path).await,
|
||||
CliCommand::Download {
|
||||
game_id,
|
||||
install_after_download,
|
||||
} => {
|
||||
ensure_catalog_game(shared, game_id).await?;
|
||||
ensure_catalog_game(shared, game_id)?;
|
||||
ensure_no_active_operation(shared, game_id).await?;
|
||||
game_files_for_download(sender, shared, game_id).await?;
|
||||
sender.send(PeerCommand::DownloadGameFilesWithOptions {
|
||||
id: game_id.clone(),
|
||||
install_after_download: *install_after_download,
|
||||
})?;
|
||||
Ok(json!({"queued": true, "game_id": game_id, "install": install_after_download}))
|
||||
}
|
||||
CliCommand::StreamInstall { game_id } => {
|
||||
ensure_catalog_game(shared, game_id).await?;
|
||||
CliCommand::StreamInstall {
|
||||
game_id,
|
||||
account_name,
|
||||
language,
|
||||
persona_name,
|
||||
} => {
|
||||
ensure_catalog_game(shared, game_id)?;
|
||||
ensure_no_active_operation(shared, game_id).await?;
|
||||
sender.send(PeerCommand::StreamInstallGame {
|
||||
id: game_id.clone(),
|
||||
settings: StreamInstallSettings::sanitized(
|
||||
account_name.as_deref(),
|
||||
language.as_deref(),
|
||||
persona_name.as_deref(),
|
||||
),
|
||||
})?;
|
||||
Ok(json!({"queued": true, "game_id": game_id}))
|
||||
}
|
||||
CliCommand::CancelDownload { game_id } => {
|
||||
ensure_catalog_game(shared, game_id).await?;
|
||||
ensure_catalog_game(shared, game_id)?;
|
||||
sender.send(PeerCommand::CancelDownload {
|
||||
id: game_id.clone(),
|
||||
})?;
|
||||
Ok(json!({"queued": true, "game_id": game_id}))
|
||||
}
|
||||
CliCommand::Install { game_id } => {
|
||||
ensure_catalog_game(shared, game_id).await?;
|
||||
ensure_catalog_game(shared, game_id)?;
|
||||
ensure_no_active_operation(shared, game_id).await?;
|
||||
sender.send(PeerCommand::InstallGame {
|
||||
id: game_id.clone(),
|
||||
@@ -312,7 +549,7 @@ async fn handle_command(
|
||||
language,
|
||||
} => play(shared, game_id, username, language.as_deref()).await,
|
||||
CliCommand::Uninstall { game_id } => {
|
||||
ensure_catalog_game(shared, game_id).await?;
|
||||
ensure_catalog_game(shared, game_id)?;
|
||||
ensure_no_active_operation(shared, game_id).await?;
|
||||
sender.send(PeerCommand::UninstallGame {
|
||||
id: game_id.clone(),
|
||||
@@ -320,21 +557,208 @@ async fn handle_command(
|
||||
Ok(json!({"queued": true, "game_id": game_id}))
|
||||
}
|
||||
CliCommand::WaitPeers { count, timeout } => wait_peers(shared, *count, *timeout).await,
|
||||
CliCommand::Connect { addr } => {
|
||||
ensure_not_self_connect(shared, *addr).await?;
|
||||
sender.send(PeerCommand::ConnectPeer(*addr))?;
|
||||
Ok(json!({"queued": true, "addr": addr.to_string()}))
|
||||
CliCommand::Connect { endpoint } => {
|
||||
ensure_not_self_connect(shared, *endpoint).await?;
|
||||
sender.send(PeerCommand::ConnectPeer(*endpoint))?;
|
||||
Ok(json!({
|
||||
"queued": true,
|
||||
"peer_id": endpoint.peer_id,
|
||||
"addr": endpoint.addr.to_string(),
|
||||
}))
|
||||
}
|
||||
CliCommand::Shutdown => {
|
||||
handle.shutdown();
|
||||
tokio::time::timeout(Duration::from_secs(5), handle.wait_stopped())
|
||||
.await
|
||||
.wrap_err("timed out waiting for peer runtime shutdown")?;
|
||||
Ok(json!({"stopped": true}))
|
||||
CliCommand::Shutdown => eyre::bail!("shutdown must be finalized by the CLI task scope"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn shutdown_signal() -> eyre::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use tokio::signal::unix::{SignalKind, signal};
|
||||
|
||||
let mut interrupt = signal(SignalKind::interrupt())?;
|
||||
let mut terminate = signal(SignalKind::terminate())?;
|
||||
let received = tokio::select! {
|
||||
received = interrupt.recv() => received,
|
||||
received = terminate.recv() => received,
|
||||
};
|
||||
if received.is_none() {
|
||||
eyre::bail!("CLI shutdown signal stream closed unexpectedly");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.wrap_err("failed to listen for Ctrl-C")
|
||||
}
|
||||
}
|
||||
|
||||
/// Joins the command loop, event loop, and signal listener around one
|
||||
/// unconditional peer-runtime finalizer.
|
||||
///
|
||||
/// The two loops are spawned only so Tokio can turn their panics and explicit
|
||||
/// cancellation into `JoinError` values. Their handles remain owned here until
|
||||
/// both tasks have settled. Peer shutdown always runs before either outcome is
|
||||
/// returned to `main`.
|
||||
async fn join_cli_scope<Signal, Shutdown>(
|
||||
mut command_task: JoinHandle<eyre::Result<CommandLoopExit>>,
|
||||
mut event_task: JoinHandle<()>,
|
||||
signal: Signal,
|
||||
shutdown: Shutdown,
|
||||
) -> eyre::Result<CliScopeOutcome>
|
||||
where
|
||||
Signal: Future<Output = eyre::Result<()>>,
|
||||
Shutdown: Future<Output = ()>,
|
||||
{
|
||||
enum FirstTask {
|
||||
Command(Result<eyre::Result<CommandLoopExit>, JoinError>),
|
||||
Events(Result<(), JoinError>),
|
||||
Signal(eyre::Result<()>),
|
||||
}
|
||||
|
||||
let first = tokio::select! {
|
||||
result = &mut command_task => FirstTask::Command(result),
|
||||
result = &mut event_task => FirstTask::Events(result),
|
||||
result = signal => FirstTask::Signal(result),
|
||||
};
|
||||
|
||||
match first {
|
||||
FirstTask::Command(command_result) => {
|
||||
// The peer owns every event sender. Stopping it first lets the
|
||||
// still-running event loop drain terminal JSONL events and then
|
||||
// finish naturally when the channel closes.
|
||||
shutdown.await;
|
||||
let event_result = event_task.await;
|
||||
|
||||
let exit = command_result.wrap_err("peer command loop task failed to join")??;
|
||||
event_result.wrap_err("peer event loop failed to join")?;
|
||||
Ok(CliScopeOutcome::Command(exit))
|
||||
}
|
||||
FirstTask::Events(event_result) => {
|
||||
// An event-loop exit terminates the CLI scope. Settle the command
|
||||
// future and peer before deciding whether this platform can unwind
|
||||
// its stdin implementation or must use the process boundary.
|
||||
command_task.abort();
|
||||
let command_result = command_task.await;
|
||||
shutdown.await;
|
||||
|
||||
match command_result {
|
||||
Ok(result) => {
|
||||
let exit = result?;
|
||||
event_result.wrap_err("peer event loop failed to join")?;
|
||||
Ok(CliScopeOutcome::Command(exit))
|
||||
}
|
||||
Err(error) if error.is_cancelled() => {
|
||||
let failure = match event_result {
|
||||
Ok(()) => eyre::eyre!("peer event loop stopped before the command loop"),
|
||||
Err(error) => {
|
||||
eyre::Report::new(error).wrap_err("peer event loop failed to join")
|
||||
}
|
||||
};
|
||||
Ok(CliScopeOutcome::CancelledInput(
|
||||
CancelledInputExit::Failure(failure),
|
||||
))
|
||||
}
|
||||
Err(error) => {
|
||||
event_result.wrap_err("peer event loop failed to join")?;
|
||||
Err(error).wrap_err("peer command loop task failed to join")
|
||||
}
|
||||
}
|
||||
}
|
||||
FirstTask::Signal(signal_result) => {
|
||||
// SIGINT/SIGTERM/Ctrl-C are owned termination inputs, not an outer
|
||||
// race that is allowed to drop this scope. Cancel the command
|
||||
// future, settle the peer, and drain all terminal events first.
|
||||
command_task.abort();
|
||||
let command_result = command_task.await;
|
||||
shutdown.await;
|
||||
let event_result = event_task.await;
|
||||
|
||||
match command_result {
|
||||
Ok(result) => {
|
||||
signal_result.wrap_err("CLI shutdown signal listener failed")?;
|
||||
let exit = result?;
|
||||
event_result.wrap_err("peer event loop failed to join")?;
|
||||
Ok(CliScopeOutcome::Command(exit))
|
||||
}
|
||||
Err(error) if error.is_cancelled() => {
|
||||
let cancellation = if let Err(error) = signal_result {
|
||||
CancelledInputExit::Failure(
|
||||
error.wrap_err("CLI shutdown signal listener failed"),
|
||||
)
|
||||
} else if let Err(error) = event_result {
|
||||
CancelledInputExit::Failure(
|
||||
eyre::Report::new(error).wrap_err("peer event loop failed to join"),
|
||||
)
|
||||
} else {
|
||||
CancelledInputExit::Signal
|
||||
};
|
||||
Ok(CliScopeOutcome::CancelledInput(cancellation))
|
||||
}
|
||||
Err(error) => {
|
||||
signal_result.wrap_err("CLI shutdown signal listener failed")?;
|
||||
event_result.wrap_err("peer event loop failed to join")?;
|
||||
Err(error).wrap_err("peer command loop task failed to join")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the only platform-dependent ownership boundary left by stdin.
|
||||
///
|
||||
/// Unix's `AsyncFd` command source is quiescent once its future is cancelled.
|
||||
/// Tokio's fallback stdin owns an uncancellable blocking read, so non-Unix must
|
||||
/// terminate the process *after* peer shutdown and task settlement instead of
|
||||
/// returning into runtime destruction. EOF and explicit `shutdown` complete as
|
||||
/// `CliScopeOutcome::Command` and never take this boundary.
|
||||
fn finish_cli_scope<Terminate>(
|
||||
outcome: CliScopeOutcome,
|
||||
input_cancellation_is_quiescent: bool,
|
||||
terminate_process: Terminate,
|
||||
) -> eyre::Result<CommandLoopExit>
|
||||
where
|
||||
Terminate: FnOnce(i32, Option<&eyre::Report>) -> eyre::Result<CommandLoopExit>,
|
||||
{
|
||||
match outcome {
|
||||
CliScopeOutcome::Command(exit) => Ok(exit),
|
||||
CliScopeOutcome::CancelledInput(CancelledInputExit::Signal)
|
||||
if input_cancellation_is_quiescent =>
|
||||
{
|
||||
Ok(CommandLoopExit::Signal)
|
||||
}
|
||||
CliScopeOutcome::CancelledInput(CancelledInputExit::Signal) => terminate_process(0, None),
|
||||
CliScopeOutcome::CancelledInput(CancelledInputExit::Failure(error))
|
||||
if input_cancellation_is_quiescent =>
|
||||
{
|
||||
Err(error)
|
||||
}
|
||||
CliScopeOutcome::CancelledInput(CancelledInputExit::Failure(error)) => {
|
||||
terminate_process(1, Some(&error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_game_dir(
|
||||
sender: &mpsc::UnboundedSender<PeerCommand>,
|
||||
shared: &SharedState,
|
||||
path: &Path,
|
||||
) -> eyre::Result<Value> {
|
||||
let (reply, result) = oneshot::channel();
|
||||
sender.send(PeerCommand::SetGameDir {
|
||||
path: path.to_path_buf(),
|
||||
reply,
|
||||
})?;
|
||||
let accepted_path = result
|
||||
.await
|
||||
.wrap_err("peer stopped before acknowledging the game directory")?
|
||||
.map_err(eyre::Report::msg)?;
|
||||
*shared.games_dir.write().await = accepted_path.clone();
|
||||
Ok(json!({"accepted": true, "path": accepted_path}))
|
||||
}
|
||||
|
||||
async fn status(shared: &SharedState) -> eyre::Result<Value> {
|
||||
let state = shared.state.read().await;
|
||||
let peer_count = shared.peer_game_db.read().await.peer_snapshots().len();
|
||||
@@ -362,11 +786,9 @@ async fn list_peers(shared: &SharedState) -> eyre::Result<Value> {
|
||||
|
||||
async fn list_games(shared: &SharedState) -> eyre::Result<Value> {
|
||||
let state = shared.state.read().await;
|
||||
let catalog = shared.catalog.read().await;
|
||||
let remote = shared.peer_game_db.read().await.get_catalog_games(&catalog);
|
||||
Ok(json!({
|
||||
"local": state.local_games.clone(),
|
||||
"remote": remote,
|
||||
"remote": state.remote_games.clone(),
|
||||
"active_operations": active_operations_json(&state.active_operations),
|
||||
}))
|
||||
}
|
||||
@@ -377,21 +799,24 @@ async fn play(
|
||||
username: &str,
|
||||
language: Option<&str>,
|
||||
) -> eyre::Result<Value> {
|
||||
ensure_catalog_game(shared, game_id).await?;
|
||||
let game_root = shared.games_dir.join(game_id);
|
||||
ensure_catalog_game(shared, game_id)?;
|
||||
let game_root = shared.games_dir.read().await.join(game_id);
|
||||
let outcome = lanspread_peer::apply_launch_settings_once(
|
||||
&shared.state_dir,
|
||||
&game_root,
|
||||
game_id,
|
||||
Some(username),
|
||||
language,
|
||||
)
|
||||
.await?;
|
||||
)?;
|
||||
Ok(json!({ "game_id": game_id, "outcome": outcome }))
|
||||
}
|
||||
|
||||
async fn ensure_catalog_game(shared: &SharedState, game_id: &str) -> eyre::Result<()> {
|
||||
if shared.catalog.read().await.contains(game_id) {
|
||||
fn ensure_catalog_game(shared: &SharedState, game_id: &str) -> eyre::Result<()> {
|
||||
if shared
|
||||
.catalog_bundle
|
||||
.as_ref()
|
||||
.is_some_and(|catalog| catalog.catalog().contains(game_id))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -411,14 +836,12 @@ async fn ensure_no_active_operation(shared: &SharedState, game_id: &str) -> eyre
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_not_self_connect(shared: &SharedState, addr: SocketAddr) -> eyre::Result<()> {
|
||||
async fn ensure_not_self_connect(shared: &SharedState, endpoint: PeerEndpoint) -> eyre::Result<()> {
|
||||
let state = shared.state.read().await;
|
||||
if state
|
||||
.local_peer
|
||||
.as_ref()
|
||||
.is_some_and(|peer| peer.addr == addr.to_string())
|
||||
{
|
||||
eyre::bail!("cannot connect peer to itself at {addr}");
|
||||
if state.local_peer.as_ref().is_some_and(|peer| {
|
||||
peer.peer_id == endpoint.peer_id.to_string() || peer.addr == endpoint.addr.to_string()
|
||||
}) {
|
||||
eyre::bail!("cannot connect peer to itself at {}", endpoint.addr);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -441,39 +864,6 @@ async fn wait_peers(shared: &SharedState, count: usize, timeout: Duration) -> ey
|
||||
Ok(json!({"peer_count": peer_count}))
|
||||
}
|
||||
|
||||
async fn game_files_for_download(
|
||||
sender: &mpsc::UnboundedSender<PeerCommand>,
|
||||
shared: &SharedState,
|
||||
game_id: &str,
|
||||
) -> eyre::Result<Vec<GameFileDescription>> {
|
||||
{
|
||||
let mut state = shared.state.write().await;
|
||||
if let Some(files) = state.game_files.get(game_id).cloned() {
|
||||
return Ok(files);
|
||||
}
|
||||
state.unavailable_games.remove(game_id);
|
||||
}
|
||||
|
||||
sender.send(PeerCommand::GetGame(game_id.to_string()))?;
|
||||
let wait = async {
|
||||
loop {
|
||||
let state = shared.state.read().await;
|
||||
if let Some(files) = state.game_files.get(game_id).cloned() {
|
||||
return Ok(files);
|
||||
}
|
||||
if state.unavailable_games.contains(game_id) {
|
||||
eyre::bail!("no peers have game {game_id}");
|
||||
}
|
||||
drop(state);
|
||||
shared.notify.notified().await;
|
||||
}
|
||||
};
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(10), wait)
|
||||
.await
|
||||
.wrap_err("timed out waiting for game file details")?
|
||||
}
|
||||
|
||||
async fn event_loop(
|
||||
mut rx_events: mpsc::UnboundedReceiver<PeerEvent>,
|
||||
shared: Arc<SharedState>,
|
||||
@@ -497,21 +887,28 @@ async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'s
|
||||
shared.state.write().await.local_peer = Some(local_peer.clone());
|
||||
("local-peer-ready", json!(local_peer))
|
||||
}
|
||||
PeerEvent::ListGames(games) => {
|
||||
let catalog = shared.catalog.read().await.clone();
|
||||
let games = games
|
||||
.into_iter()
|
||||
.filter(|game| catalog.contains(&game.id))
|
||||
.collect::<Vec<_>>();
|
||||
PeerEvent::LocalNetworkSharingStateChanged(state) => (
|
||||
"local-network-sharing-state-changed",
|
||||
json!({ "state": state }),
|
||||
),
|
||||
PeerEvent::RemoteLibraryView(view) => {
|
||||
let games = join_remote_library_view(shared, &view);
|
||||
shared.state.write().await.remote_games = games.clone();
|
||||
("list-games", json!({ "games": games }))
|
||||
(
|
||||
"remote-library-view",
|
||||
json!({ "view": view, "games": games }),
|
||||
)
|
||||
}
|
||||
PeerEvent::LocalLibraryChanged { games } => {
|
||||
let mut state = shared.state.write().await;
|
||||
state.local_games.clone_from(&games);
|
||||
("local-library-changed", json!({ "games": games }))
|
||||
}
|
||||
PeerEvent::OutboundTransferCountChanged => ("outbound-transfer-count-changed", json!({})),
|
||||
PeerEvent::OutboundTransferCountChanged(change) => {
|
||||
let event = ("outbound-transfer-count-changed", json!({}));
|
||||
drop(change);
|
||||
event
|
||||
}
|
||||
PeerEvent::ActiveOperationsChanged { active_operations } => {
|
||||
let mut state = shared.state.write().await;
|
||||
state.active_operations.clone_from(&active_operations);
|
||||
@@ -520,63 +917,61 @@ async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'s
|
||||
json!({ "active_operations": active_operations_json(&active_operations) }),
|
||||
)
|
||||
}
|
||||
PeerEvent::CallToPlayEvents(events) => {
|
||||
let mut state = shared.state.write().await;
|
||||
let mut known = state
|
||||
.call_to_play_events
|
||||
.iter()
|
||||
.map(|event| event.id.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
state.call_to_play_events.extend(
|
||||
events
|
||||
.iter()
|
||||
.filter(|event| known.insert(event.id.clone()))
|
||||
.cloned(),
|
||||
);
|
||||
("call-to-play-events", json!({ "events": events }))
|
||||
PeerEvent::CallToPlayView(view) => {
|
||||
shared.state.write().await.call_to_play_view = view.clone();
|
||||
("call-to-play-view", json!({ "view": view }))
|
||||
}
|
||||
PeerEvent::GotGameFiles {
|
||||
id,
|
||||
file_descriptions,
|
||||
} => {
|
||||
shared
|
||||
.state
|
||||
.write()
|
||||
.await
|
||||
.game_files
|
||||
.insert(id.clone(), file_descriptions.clone());
|
||||
(
|
||||
"got-game-files",
|
||||
json!({"game_id": id, "file_descriptions": file_descriptions}),
|
||||
)
|
||||
PeerEvent::DownloadGameFilesBegin { attempt } => {
|
||||
download_begin_event(shared, attempt).await
|
||||
}
|
||||
PeerEvent::DownloadGameFilesBegin { id } => download_begin_event(shared, id).await,
|
||||
PeerEvent::DownloadGameFileChunkFinished {
|
||||
id,
|
||||
peer_id,
|
||||
peer_addr,
|
||||
content_id,
|
||||
relative_path,
|
||||
offset,
|
||||
length,
|
||||
} => {
|
||||
download_chunk_finished_event(shared, id, peer_addr, relative_path, offset, length)
|
||||
.await
|
||||
download_chunk_finished_event(
|
||||
shared,
|
||||
DownloadChunkFinishedEvent {
|
||||
id,
|
||||
peer_id: peer_id.to_string(),
|
||||
peer_addr,
|
||||
content_id: content_id.to_string(),
|
||||
relative_path: relative_path.as_str().to_owned(),
|
||||
offset,
|
||||
length,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
PeerEvent::DownloadGameFilesProgress(progress) => (
|
||||
"download-progress",
|
||||
json!({
|
||||
"game_id": progress.id,
|
||||
"game_id": progress.attempt.id,
|
||||
"attempt_id": progress.attempt.attempt_id.to_string(),
|
||||
"downloaded_bytes": progress.downloaded_bytes,
|
||||
"total_bytes": progress.total_bytes,
|
||||
"bytes_per_second": progress.bytes_per_second,
|
||||
"active_peer_count": progress.active_peer_count,
|
||||
}),
|
||||
),
|
||||
PeerEvent::DownloadGameFilesFinished { id } => {
|
||||
download_terminal_event(shared, "download-finished", id).await
|
||||
PeerEvent::DownloadGameFilesActivityChanged { attempt, activity } => (
|
||||
"download-activity-changed",
|
||||
json!({
|
||||
"game_id": attempt.id,
|
||||
"attempt_id": attempt.attempt_id.to_string(),
|
||||
"activity": activity,
|
||||
}),
|
||||
),
|
||||
PeerEvent::DownloadGameFilesFinished { attempt } => {
|
||||
download_terminal_event(shared, "download-finished", attempt, None).await
|
||||
}
|
||||
PeerEvent::DownloadGameFilesFailed { id } => {
|
||||
download_terminal_event(shared, "download-failed", id).await
|
||||
PeerEvent::DownloadGameFilesFailed { attempt, reason } => {
|
||||
download_terminal_event(shared, "download-failed", attempt, Some(reason)).await
|
||||
}
|
||||
PeerEvent::DownloadGameFilesAllPeersGone { id } => game_id_event("download-peers-gone", id),
|
||||
PeerEvent::InstallGameFinished { id } => game_id_event("install-finished", id),
|
||||
PeerEvent::InstallGameFailed { id } => game_id_event("install-failed", id),
|
||||
PeerEvent::UninstallGameFinished { id } => game_id_event("uninstall-finished", id),
|
||||
@@ -585,12 +980,13 @@ async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'s
|
||||
game_id_event("remove-download-finished", id)
|
||||
}
|
||||
PeerEvent::RemoveDownloadedGameFailed { id } => game_id_event("remove-download-failed", id),
|
||||
PeerEvent::NoPeersHaveGame { id } => no_peers_event(shared, id).await,
|
||||
PeerEvent::PeerConnected(addr) => ("peer-connected", peer_addr_json(addr)),
|
||||
PeerEvent::PeerDisconnected(addr) => ("peer-disconnected", peer_addr_json(addr)),
|
||||
PeerEvent::PeerDiscovered(addr) => ("peer-discovered", peer_addr_json(addr)),
|
||||
PeerEvent::PeerLost(addr) => ("peer-lost", peer_addr_json(addr)),
|
||||
PeerEvent::PeerDiscovered(endpoint) => ("peer-discovered", peer_endpoint_json(endpoint)),
|
||||
PeerEvent::PeerLost(endpoint) => ("peer-lost", peer_endpoint_json(endpoint)),
|
||||
PeerEvent::PeerCountUpdated(count) => ("peer-count-updated", json!({"count": count})),
|
||||
PeerEvent::IncompatibleProtocolDetected { observed, expected } => (
|
||||
"incompatible-protocol-detected",
|
||||
json!({"observed": observed, "expected": expected}),
|
||||
),
|
||||
PeerEvent::RuntimeFailed { component, error } => (
|
||||
"runtime-failed",
|
||||
json!({"component": runtime_component_name(component), "error": error}),
|
||||
@@ -598,43 +994,102 @@ async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'s
|
||||
}
|
||||
}
|
||||
|
||||
fn join_remote_library_view(shared: &SharedState, view: &RemoteLibraryView) -> Vec<Game> {
|
||||
let mut games = view
|
||||
.games
|
||||
.iter()
|
||||
.filter_map(|availability| {
|
||||
let catalog_bundle = shared.catalog_bundle.as_ref()?;
|
||||
let Some(identity) = catalog_bundle.content_identity(&availability.game_id) else {
|
||||
eprintln!(
|
||||
"Ignoring remote availability for unknown catalog game {}",
|
||||
availability.game_id
|
||||
);
|
||||
return None;
|
||||
};
|
||||
if identity.content_id != availability.content_id {
|
||||
eprintln!(
|
||||
"Ignoring remote availability for non-catalog content {} ({})",
|
||||
availability.game_id, availability.content_id
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let mut game = shared
|
||||
.catalog_game_db
|
||||
.get_game_by_id(&availability.game_id)?
|
||||
.clone();
|
||||
game.peer_count = availability.peer_count;
|
||||
game.set_downloaded(true);
|
||||
Some(game)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
games.sort();
|
||||
games
|
||||
}
|
||||
|
||||
fn game_id_event(kind: &'static str, id: String) -> (&'static str, Value) {
|
||||
(kind, json!({"game_id": id}))
|
||||
}
|
||||
|
||||
async fn download_begin_event(shared: &SharedState, id: String) -> (&'static str, Value) {
|
||||
shared.state.write().await.downloads.insert(
|
||||
id.clone(),
|
||||
DownloadMeasurement {
|
||||
started_at: Instant::now(),
|
||||
bytes: 0,
|
||||
chunks: 0,
|
||||
},
|
||||
);
|
||||
game_id_event("download-begin", id)
|
||||
async fn download_begin_event(
|
||||
shared: &SharedState,
|
||||
attempt: DownloadAttemptKey,
|
||||
) -> (&'static str, Value) {
|
||||
let mut state = shared.state.write().await;
|
||||
let replace = state
|
||||
.downloads
|
||||
.get(&attempt.id)
|
||||
.is_none_or(|measurement| measurement.attempt_id < attempt.attempt_id);
|
||||
if replace {
|
||||
state.downloads.insert(
|
||||
attempt.id.clone(),
|
||||
DownloadMeasurement {
|
||||
attempt_id: attempt.attempt_id,
|
||||
started_at: Instant::now(),
|
||||
bytes: 0,
|
||||
chunks: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
drop(state);
|
||||
(
|
||||
"download-begin",
|
||||
json!({
|
||||
"game_id": attempt.id,
|
||||
"attempt_id": attempt.attempt_id.to_string(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
struct DownloadChunkFinishedEvent {
|
||||
id: String,
|
||||
peer_id: String,
|
||||
peer_addr: SocketAddr,
|
||||
content_id: String,
|
||||
relative_path: String,
|
||||
offset: u64,
|
||||
length: u64,
|
||||
}
|
||||
|
||||
async fn download_chunk_finished_event(
|
||||
shared: &SharedState,
|
||||
id: String,
|
||||
peer_addr: SocketAddr,
|
||||
relative_path: String,
|
||||
offset: u64,
|
||||
length: u64,
|
||||
event: DownloadChunkFinishedEvent,
|
||||
) -> (&'static str, Value) {
|
||||
if let Some(measurement) = shared.state.write().await.downloads.get_mut(&id) {
|
||||
measurement.bytes = measurement.bytes.saturating_add(length);
|
||||
if let Some(measurement) = shared.state.write().await.downloads.get_mut(&event.id) {
|
||||
measurement.bytes = measurement.bytes.saturating_add(event.length);
|
||||
measurement.chunks = measurement.chunks.saturating_add(1);
|
||||
}
|
||||
|
||||
(
|
||||
"download-chunk-finished",
|
||||
json!({
|
||||
"game_id": id,
|
||||
"peer_addr": peer_addr.to_string(),
|
||||
"relative_path": relative_path,
|
||||
"offset": offset,
|
||||
"length": length,
|
||||
"game_id": event.id,
|
||||
"peer_id": event.peer_id,
|
||||
"peer_addr": event.peer_addr.to_string(),
|
||||
"content_id": event.content_id,
|
||||
"relative_path": event.relative_path,
|
||||
"offset": event.offset,
|
||||
"length": event.length,
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -642,11 +1097,29 @@ async fn download_chunk_finished_event(
|
||||
async fn download_terminal_event(
|
||||
shared: &SharedState,
|
||||
kind: &'static str,
|
||||
id: String,
|
||||
attempt: DownloadAttemptKey,
|
||||
reason: Option<DownloadFailureReason>,
|
||||
) -> (&'static str, Value) {
|
||||
let measurement = shared.state.write().await.downloads.remove(&id);
|
||||
let measurement = {
|
||||
let mut state = shared.state.write().await;
|
||||
let is_current = state
|
||||
.downloads
|
||||
.get(&attempt.id)
|
||||
.is_some_and(|measurement| measurement.attempt_id == attempt.attempt_id);
|
||||
is_current
|
||||
.then(|| state.downloads.remove(&attempt.id))
|
||||
.flatten()
|
||||
};
|
||||
|
||||
let mut data = json!({
|
||||
"game_id": attempt.id,
|
||||
"attempt_id": attempt.attempt_id.to_string(),
|
||||
});
|
||||
if let Some(reason) = reason {
|
||||
data["reason"] = json!(reason);
|
||||
}
|
||||
let Some(measurement) = measurement else {
|
||||
return game_id_event(kind, id);
|
||||
return (kind, data);
|
||||
};
|
||||
|
||||
let duration = measurement.started_at.elapsed();
|
||||
@@ -654,33 +1127,21 @@ async fn download_terminal_event(
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let bytes = measurement.bytes as f64;
|
||||
|
||||
(
|
||||
kind,
|
||||
json!({
|
||||
"game_id": id,
|
||||
"throughput": {
|
||||
"bytes": measurement.bytes,
|
||||
"chunks": measurement.chunks,
|
||||
"duration_ms": duration.as_secs_f64() * 1000.0,
|
||||
"mib_per_s": bytes / seconds / 1_048_576.0,
|
||||
"mbit_per_s": bytes * 8.0 / seconds / 1_000_000.0,
|
||||
},
|
||||
}),
|
||||
)
|
||||
data["throughput"] = json!({
|
||||
"bytes": measurement.bytes,
|
||||
"chunks": measurement.chunks,
|
||||
"duration_ms": duration.as_secs_f64() * 1000.0,
|
||||
"mib_per_s": bytes / seconds / 1_048_576.0,
|
||||
"mbit_per_s": bytes * 8.0 / seconds / 1_000_000.0,
|
||||
});
|
||||
(kind, data)
|
||||
}
|
||||
|
||||
async fn no_peers_event(shared: &SharedState, id: String) -> (&'static str, Value) {
|
||||
shared
|
||||
.state
|
||||
.write()
|
||||
.await
|
||||
.unavailable_games
|
||||
.insert(id.clone());
|
||||
game_id_event("no-peers-have-game", id)
|
||||
}
|
||||
|
||||
fn peer_addr_json(addr: SocketAddr) -> Value {
|
||||
json!({"addr": addr.to_string()})
|
||||
fn peer_endpoint_json(endpoint: lanspread_peer::PeerEndpoint) -> Value {
|
||||
json!({
|
||||
"peer_id": endpoint.peer_id.to_string(),
|
||||
"addr": endpoint.addr.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn active_operations_json(active_operations: &[ActiveOperation]) -> Vec<Value> {
|
||||
@@ -700,11 +1161,12 @@ fn peer_snapshots_json(peers: &[PeerSnapshot]) -> Vec<Value> {
|
||||
.iter()
|
||||
.map(|peer| {
|
||||
json!({
|
||||
"peer_id": peer.peer_id.clone(),
|
||||
"peer_id": peer.peer_id,
|
||||
"addr": peer.addr.to_string(),
|
||||
"library_rev": peer.library_rev,
|
||||
"library_digest": peer.library_digest,
|
||||
"features": peer.features.clone(),
|
||||
"endpoint_generation": peer.endpoint_generation.get(),
|
||||
"runtime_session_id": peer.runtime_session_id,
|
||||
"library_revision": peer.library_revision,
|
||||
"call_to_play_revision": peer.call_to_play_revision,
|
||||
"game_count": peer.game_count,
|
||||
"games": peer.games.clone(),
|
||||
})
|
||||
@@ -727,40 +1189,65 @@ fn seed_fixtures(game_dir: &Path, fixtures: &[String]) -> eyre::Result<Vec<Fixtu
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn load_catalog(catalog_db: Option<&Path>, fixtures: &[FixtureSeed]) -> GameCatalog {
|
||||
let mut catalog = GameCatalog::empty();
|
||||
if let Some(path) = catalog_db
|
||||
&& path.exists()
|
||||
{
|
||||
match get_games(path).await {
|
||||
Ok(games) => {
|
||||
for game in games {
|
||||
catalog.insert(game.game_id, Some(game.game_version));
|
||||
}
|
||||
}
|
||||
Err(err) => eprintln!("failed to load catalog db {}: {err}", path.display()),
|
||||
}
|
||||
}
|
||||
async fn load_catalog_authority(
|
||||
catalog_db: &Path,
|
||||
manifests_dir: &Path,
|
||||
fixtures: &[String],
|
||||
) -> eyre::Result<(GameDB, Arc<CatalogBundle>)> {
|
||||
let loaded = load_catalog_bundle(catalog_db, manifests_dir)
|
||||
.await
|
||||
.wrap_err_with(|| {
|
||||
format!(
|
||||
"failed to load catalog authority from {} and {}",
|
||||
catalog_db.display(),
|
||||
manifests_dir.display()
|
||||
)
|
||||
})?;
|
||||
let (game_db, bundle) = loaded.into_parts();
|
||||
// A selected fixture body can be large and is backed by synchronous,
|
||||
// bounded filesystem reads. This startup gate runs before the peer owns
|
||||
// any tasks; keep the work lexical while letting Tokio compensate for the
|
||||
// blocked CLI executor worker.
|
||||
tokio::task::block_in_place(|| validate_fixture_authority(&bundle, fixtures))?;
|
||||
Ok((game_db, bundle))
|
||||
}
|
||||
|
||||
for seed in fixtures {
|
||||
catalog.insert(
|
||||
seed.game_id.clone(),
|
||||
Some(DEFAULT_FIXTURE_VERSION.to_string()),
|
||||
);
|
||||
fn validate_fixture_authority(
|
||||
catalog_bundle: &CatalogBundle,
|
||||
fixtures: &[String],
|
||||
) -> eyre::Result<()> {
|
||||
for fixture in fixtures {
|
||||
let expected_version = catalog_bundle
|
||||
.catalog()
|
||||
.expected_version(fixture)
|
||||
.ok_or_else(|| {
|
||||
eyre::eyre!("fixture {fixture} is not authorized by the selected catalog profile")
|
||||
})?;
|
||||
if expected_version != DEFAULT_FIXTURE_VERSION {
|
||||
eyre::bail!(
|
||||
"fixture {fixture} requires catalog version {DEFAULT_FIXTURE_VERSION}, but the selected profile authorizes {expected_version}"
|
||||
);
|
||||
}
|
||||
catalog_bundle.manifest(fixture).wrap_err_with(|| {
|
||||
format!("fixture {fixture} has no valid manifest in the selected catalog profile")
|
||||
})?;
|
||||
}
|
||||
catalog
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_args() -> eyre::Result<Args> {
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
let mut parsed = Args {
|
||||
name: "peer".to_string(),
|
||||
games_dir: PathBuf::from("games"),
|
||||
state_dir: PathBuf::from("state"),
|
||||
catalog_db: default_catalog_db(),
|
||||
fixtures: Vec::new(),
|
||||
unrar: None,
|
||||
};
|
||||
parse_args_from(std::env::args_os().skip(1))
|
||||
}
|
||||
|
||||
fn parse_args_from(mut args: impl Iterator<Item = OsString>) -> eyre::Result<Args> {
|
||||
let mut name = "peer".to_string();
|
||||
let mut games_dir = PathBuf::from("games");
|
||||
let mut state_dir = PathBuf::from("state");
|
||||
let mut identity_file = None;
|
||||
let mut catalog_db = default_catalog_db();
|
||||
let mut manifests_dir = None;
|
||||
let mut fixtures = Vec::new();
|
||||
let mut unrar = None;
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.to_str() {
|
||||
@@ -768,27 +1255,49 @@ fn parse_args() -> eyre::Result<Args> {
|
||||
print_help();
|
||||
std::process::exit(0);
|
||||
}
|
||||
Some("--name") => parsed.name = next_string(&mut args, "--name")?,
|
||||
Some("--games-dir") => parsed.games_dir = next_path(&mut args, "--games-dir")?,
|
||||
Some("--state-dir") => parsed.state_dir = next_path(&mut args, "--state-dir")?,
|
||||
Some("--catalog-db") => parsed.catalog_db = Some(next_path(&mut args, "--catalog-db")?),
|
||||
Some("--fixture") => parsed.fixtures.push(next_string(&mut args, "--fixture")?),
|
||||
Some("--unrar") => parsed.unrar = Some(next_path(&mut args, "--unrar")?),
|
||||
Some("--name") => name = next_string(&mut args, "--name")?,
|
||||
Some("--games-dir") => games_dir = next_path(&mut args, "--games-dir")?,
|
||||
Some("--state-dir") => state_dir = next_path(&mut args, "--state-dir")?,
|
||||
Some("--identity-file") => {
|
||||
identity_file = Some(next_path(&mut args, "--identity-file")?);
|
||||
}
|
||||
Some("--catalog-db") => catalog_db = next_path(&mut args, "--catalog-db")?,
|
||||
Some("--manifests-dir") => {
|
||||
manifests_dir = Some(next_path(&mut args, "--manifests-dir")?);
|
||||
}
|
||||
Some("--fixture") => fixtures.push(next_string(&mut args, "--fixture")?),
|
||||
Some("--unrar") => unrar = Some(next_path(&mut args, "--unrar")?),
|
||||
Some(other) => eyre::bail!("unknown argument: {other}"),
|
||||
None => eyre::bail!("argument is not valid UTF-8: {arg:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(parsed)
|
||||
let manifests_dir = manifests_dir.unwrap_or_else(|| sibling_manifests_dir(&catalog_db));
|
||||
Ok(Args {
|
||||
name,
|
||||
games_dir,
|
||||
state_dir,
|
||||
identity_file,
|
||||
catalog_db,
|
||||
manifests_dir,
|
||||
fixtures,
|
||||
unrar,
|
||||
})
|
||||
}
|
||||
|
||||
fn default_catalog_db() -> Option<PathBuf> {
|
||||
[
|
||||
PathBuf::from("/app/game.db"),
|
||||
PathBuf::from("crates/lanspread-tauri-deno-ts/src-tauri/game.db"),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|path| path.exists())
|
||||
fn default_catalog_db() -> PathBuf {
|
||||
let container = PathBuf::from("/app/game.db");
|
||||
if container.exists() {
|
||||
return container;
|
||||
}
|
||||
PathBuf::from("crates/lanspread-peer-cli/catalogs/default/game.db")
|
||||
}
|
||||
|
||||
fn sibling_manifests_dir(catalog_db: &Path) -> PathBuf {
|
||||
catalog_db
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join("manifests")
|
||||
}
|
||||
|
||||
fn default_unrar_program() -> Option<PathBuf> {
|
||||
@@ -811,10 +1320,841 @@ fn next_path(args: &mut impl Iterator<Item = OsString>, flag: &str) -> eyre::Res
|
||||
Ok(PathBuf::from(next_string(args, flag)?))
|
||||
}
|
||||
|
||||
const HELP: &str = "usage: lanspread-peer-cli [--name NAME] [--games-dir PATH] [--state-dir PATH] \
|
||||
[--identity-file PATH] \
|
||||
[--catalog-db PATH] [--manifests-dir PATH] [--fixture GAME_ID] [--unrar PATH]\n\
|
||||
--identity-file loads one existing identity strictly and never repairs or replaces it.\n\
|
||||
--manifests-dir defaults to the manifests/ sibling of --catalog-db.\n\
|
||||
Every --fixture must be authorized by the selected catalog profile.\n\
|
||||
Reads JSONL commands on stdin and writes result/event/error JSONL on stdout.";
|
||||
|
||||
fn print_help() {
|
||||
eprintln!(
|
||||
"usage: lanspread-peer-cli [--name NAME] [--games-dir PATH] [--state-dir PATH] \\
|
||||
[--catalog-db PATH] [--fixture GAME_ID] [--unrar PATH]\n\
|
||||
Reads JSONL commands on stdin and writes result/event/error JSONL on stdout."
|
||||
);
|
||||
eprintln!("{HELP}");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
};
|
||||
#[cfg(unix)]
|
||||
use std::{os::fd::OwnedFd, process::Stdio};
|
||||
|
||||
use lanspread_db::content_manifest::{
|
||||
Blake3Digest,
|
||||
CATALOG_CONTENT_INDEX_NAME,
|
||||
CatalogContentIndex,
|
||||
CatalogContentManifest,
|
||||
CatalogContentManifestBody,
|
||||
CatalogFileEntry,
|
||||
write_canonical_content_index_atomic,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
static NEXT_TEMP_FILE: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl TestDir {
|
||||
fn new(label: &str) -> Self {
|
||||
let path = temp_file_path(label);
|
||||
std::fs::create_dir(&path).expect("test directory should be created");
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_file_path(label: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"lanspread-peer-cli-{label}-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT_TEMP_FILE.fetch_add(1, Ordering::Relaxed)
|
||||
))
|
||||
}
|
||||
|
||||
fn fixture_manifest(game_id: &str, version: &str) -> CatalogContentManifest {
|
||||
let archive = format!("fixture archive for {game_id}\n");
|
||||
let archive_hash = Blake3Digest::hash(archive.as_bytes());
|
||||
let version_hash = Blake3Digest::hash(version.as_bytes());
|
||||
CatalogContentManifest::seal(
|
||||
CatalogContentManifestBody::new(
|
||||
game_id,
|
||||
version,
|
||||
vec![
|
||||
CatalogFileEntry::file(
|
||||
format!("{game_id}.eti"),
|
||||
u64::try_from(archive.len()).expect("fixture archive length should fit"),
|
||||
archive_hash,
|
||||
vec![archive_hash],
|
||||
)
|
||||
.expect("fixture archive entry should validate"),
|
||||
CatalogFileEntry::file(
|
||||
"version.ini",
|
||||
u64::try_from(version.len()).expect("fixture version length should fit"),
|
||||
version_hash,
|
||||
vec![version_hash],
|
||||
)
|
||||
.expect("fixture version entry should validate"),
|
||||
],
|
||||
Vec::new(),
|
||||
)
|
||||
.expect("fixture manifest body should validate"),
|
||||
)
|
||||
.expect("fixture manifest should seal")
|
||||
}
|
||||
|
||||
fn fixture_bundle(game_id: &str, version: &str) -> (TestDir, CatalogBundle) {
|
||||
let root = TestDir::new("catalog-authority");
|
||||
let manifest = fixture_manifest(game_id, version);
|
||||
let index = CatalogContentIndex::from_manifests([&manifest])
|
||||
.expect("fixture content index should validate");
|
||||
write_canonical_content_index_atomic(&root.0.join(CATALOG_CONTENT_INDEX_NAME), &index)
|
||||
.expect("fixture content index should be written");
|
||||
std::fs::write(
|
||||
root.0.join(format!("{game_id}.json")),
|
||||
manifest
|
||||
.to_canonical_json()
|
||||
.expect("fixture manifest should encode"),
|
||||
)
|
||||
.expect("fixture manifest should be written");
|
||||
let bundle = CatalogBundle::new(
|
||||
&root.0,
|
||||
BTreeMap::from([(game_id.to_owned(), version.to_owned())]),
|
||||
)
|
||||
.expect("fixture catalog bundle should validate");
|
||||
(root, bundle)
|
||||
}
|
||||
|
||||
fn game_fixture(game_id: &str, version: &str) -> Game {
|
||||
Game {
|
||||
id: game_id.to_owned(),
|
||||
name: format!("Catalog {game_id}"),
|
||||
description: String::new(),
|
||||
release_year: String::new(),
|
||||
publisher: String::new(),
|
||||
max_players: 4,
|
||||
version: String::new(),
|
||||
genre: String::new(),
|
||||
size: 42,
|
||||
downloaded: false,
|
||||
installed: false,
|
||||
availability: lanspread_db::db::Availability::LocalOnly,
|
||||
eti_game_version: Some(version.to_owned()),
|
||||
local_version: None,
|
||||
peer_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_manifest_path_defaults_to_selected_database_sibling() {
|
||||
let parsed = parse_args_from(
|
||||
["--catalog-db", "/profiles/solid/game.db"]
|
||||
.into_iter()
|
||||
.map(OsString::from),
|
||||
)
|
||||
.expect("explicit catalog profile should parse");
|
||||
|
||||
assert_eq!(parsed.catalog_db, Path::new("/profiles/solid/game.db"));
|
||||
assert_eq!(parsed.manifests_dir, Path::new("/profiles/solid/manifests"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_exposes_catalog_authority_profile_inputs() {
|
||||
assert!(HELP.contains("--catalog-db PATH"));
|
||||
assert!(HELP.contains("--manifests-dir PATH"));
|
||||
assert!(HELP.contains("--identity-file PATH"));
|
||||
assert!(HELP.contains("never repairs or replaces"));
|
||||
assert!(HELP.contains("manifests/ sibling"));
|
||||
assert!(HELP.contains("fixture must be authorized"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_identity_path_is_preserved_by_argument_parsing() {
|
||||
let parsed = parse_args_from(
|
||||
["--identity-file", "/identities/alpha.json"]
|
||||
.into_iter()
|
||||
.map(OsString::from),
|
||||
)
|
||||
.expect("explicit identity path should parse");
|
||||
|
||||
assert_eq!(
|
||||
parsed.identity_file.as_deref(),
|
||||
Some(Path::new("/identities/alpha.json"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_explicit_identity_fails_without_mutating_its_directory() {
|
||||
let root = TestDir::new("invalid-explicit-identity");
|
||||
let identity_path = root.0.join("selected.json");
|
||||
let invalid = b"not a peer identity\n";
|
||||
std::fs::write(&identity_path, invalid).expect("invalid identity should be written");
|
||||
|
||||
let error = load_explicit_identity(Some(&identity_path))
|
||||
.expect_err("invalid explicit identity must fail closed");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("failed to load explicitly selected peer identity")
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(&identity_path).expect("identity should remain readable"),
|
||||
invalid
|
||||
);
|
||||
let entries = std::fs::read_dir(&root.0)
|
||||
.expect("identity directory should remain readable")
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.expect("identity directory entries should be readable");
|
||||
assert_eq!(entries.len(), 1, "strict loading must not create sidecars");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_manifest_path_is_independent_of_argument_order() {
|
||||
let parsed = parse_args_from(
|
||||
[
|
||||
"--manifests-dir",
|
||||
"/profiles/dynamic/authority",
|
||||
"--catalog-db",
|
||||
"/profiles/dynamic/catalog.sqlite",
|
||||
]
|
||||
.into_iter()
|
||||
.map(OsString::from),
|
||||
)
|
||||
.expect("explicit profile paths should parse");
|
||||
|
||||
assert_eq!(
|
||||
parsed.manifests_dir,
|
||||
Path::new("/profiles/dynamic/authority")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_fixture_must_have_exact_valid_authority() {
|
||||
let (_root, bundle) = fixture_bundle("fixture-one", DEFAULT_FIXTURE_VERSION);
|
||||
validate_fixture_authority(&bundle, &["fixture-one".to_owned()])
|
||||
.expect("matching fixture authority should be accepted");
|
||||
|
||||
let error = validate_fixture_authority(&bundle, &["fixture-two".to_owned()])
|
||||
.expect_err("an unknown fixture must not be inserted into authority");
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("is not authorized by the selected catalog profile")
|
||||
);
|
||||
assert!(
|
||||
!bundle.catalog().contains("fixture-two"),
|
||||
"fixture validation must never extend catalog authority"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_fixture_rejects_wrong_version_or_malformed_manifest() {
|
||||
let (_root, wrong_version) = fixture_bundle("fixture-one", "20240101");
|
||||
let error = validate_fixture_authority(&wrong_version, &["fixture-one".to_owned()])
|
||||
.expect_err("fixture version drift must fail closed");
|
||||
assert!(error.to_string().contains(DEFAULT_FIXTURE_VERSION));
|
||||
|
||||
let root = TestDir::new("malformed-catalog-authority");
|
||||
let indexed_manifest = fixture_manifest("fixture-one", DEFAULT_FIXTURE_VERSION);
|
||||
let index = CatalogContentIndex::from_manifests([&indexed_manifest])
|
||||
.expect("fixture content index should validate");
|
||||
write_canonical_content_index_atomic(&root.0.join(CATALOG_CONTENT_INDEX_NAME), &index)
|
||||
.expect("fixture content index should be written");
|
||||
std::fs::write(root.0.join("fixture-one.json"), b"not JSON\n")
|
||||
.expect("malformed manifest fixture should be written");
|
||||
let malformed = CatalogBundle::new(
|
||||
&root.0,
|
||||
BTreeMap::from([("fixture-one".to_owned(), DEFAULT_FIXTURE_VERSION.to_owned())]),
|
||||
)
|
||||
.expect("bundle coverage should remain lazy");
|
||||
let error = validate_fixture_authority(&malformed, &["fixture-one".to_owned()])
|
||||
.expect_err("selected fixture manifest must be eagerly validated");
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("has no valid manifest in the selected catalog profile")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_view_join_uses_index_without_loading_manifest_body() {
|
||||
let game_id = "fixture-one";
|
||||
let version = DEFAULT_FIXTURE_VERSION;
|
||||
let (root, bundle) = fixture_bundle(game_id, version);
|
||||
let content_id = bundle
|
||||
.content_identity(game_id)
|
||||
.expect("fixture identity should be indexed")
|
||||
.content_id;
|
||||
std::fs::write(
|
||||
root.0.join(format!("{game_id}.json")),
|
||||
b"broken after load\n",
|
||||
)
|
||||
.expect("manifest body should become unreadable for the adapter proof");
|
||||
|
||||
let mut shared = shared_state(PathBuf::from("games"));
|
||||
shared.catalog_game_db = Arc::new(GameDB::from(vec![game_fixture(game_id, version)]));
|
||||
shared.catalog_bundle = Some(Arc::new(bundle));
|
||||
|
||||
let exact = join_remote_library_view(
|
||||
&shared,
|
||||
&RemoteLibraryView {
|
||||
games: vec![lanspread_peer::RemoteGameAvailability {
|
||||
game_id: game_id.to_owned(),
|
||||
content_id,
|
||||
peer_count: 3,
|
||||
}],
|
||||
},
|
||||
);
|
||||
assert_eq!(exact.len(), 1);
|
||||
assert_eq!(exact[0].id, game_id);
|
||||
assert_eq!(exact[0].peer_count, 3);
|
||||
|
||||
let wrong = join_remote_library_view(
|
||||
&shared,
|
||||
&RemoteLibraryView {
|
||||
games: vec![lanspread_peer::RemoteGameAvailability {
|
||||
game_id: game_id.to_owned(),
|
||||
content_id: lanspread_db::content_manifest::ContentId::from_bytes([7; 32]),
|
||||
peer_count: 9,
|
||||
}],
|
||||
},
|
||||
);
|
||||
assert!(wrong.is_empty());
|
||||
assert!(
|
||||
shared
|
||||
.catalog_bundle
|
||||
.as_ref()
|
||||
.expect("fixture bundle should remain configured")
|
||||
.manifest(game_id)
|
||||
.is_err(),
|
||||
"the adapter result must not depend on the now-invalid manifest body"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_catalog_authority_is_a_startup_error() {
|
||||
let root = TestDir::new("missing-catalog-authority");
|
||||
let error = load_catalog_authority(
|
||||
&root.0.join("missing-game.db"),
|
||||
&root.0.join("missing-manifests"),
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.expect_err("missing authority must fail instead of starting with an empty catalog");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("failed to load catalog authority")
|
||||
);
|
||||
}
|
||||
|
||||
fn shared_state(game_dir: PathBuf) -> SharedState {
|
||||
SharedState {
|
||||
state: RwLock::new(CliState::default()),
|
||||
peer_game_db: Arc::new(RwLock::new(PeerGameDB::new())),
|
||||
catalog_game_db: Arc::new(GameDB::empty()),
|
||||
catalog_bundle: None,
|
||||
call_to_play_display_name: RwLock::new("peer-cli".to_owned()),
|
||||
active_outbound_transfers: Arc::new(RwLock::new(HashMap::new())),
|
||||
notify: Notify::new(),
|
||||
games_dir: RwLock::new(game_dir),
|
||||
state_dir: PathBuf::from("state"),
|
||||
}
|
||||
}
|
||||
|
||||
fn download_attempt(game_id: &str, attempt_id: u64) -> DownloadAttemptKey {
|
||||
serde_json::from_value(json!({
|
||||
"id": game_id,
|
||||
"attempt_id": attempt_id.to_string(),
|
||||
}))
|
||||
.expect("test attempt key should use the public wire shape")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_download_terminal_cannot_reap_newer_attempt_measurement() {
|
||||
let shared = shared_state(PathBuf::from("games"));
|
||||
let older = download_attempt("game", 1);
|
||||
let newer = download_attempt("game", 2);
|
||||
|
||||
let (_, older_begin) = download_begin_event(&shared, older.clone()).await;
|
||||
let (_, newer_begin) = download_begin_event(&shared, newer.clone()).await;
|
||||
assert_eq!(older_begin["attempt_id"], "1");
|
||||
assert_eq!(newer_begin["attempt_id"], "2");
|
||||
|
||||
let (_, stale_terminal) = download_terminal_event(
|
||||
&shared,
|
||||
"download-failed",
|
||||
older,
|
||||
Some(DownloadFailureReason::VerifiedCatalogSourcesExhausted),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(stale_terminal["attempt_id"], "1");
|
||||
assert_eq!(
|
||||
stale_terminal["reason"],
|
||||
"verified-catalog-sources-exhausted"
|
||||
);
|
||||
assert!(stale_terminal.get("throughput").is_none());
|
||||
|
||||
let (_, _) = download_chunk_finished_event(
|
||||
&shared,
|
||||
DownloadChunkFinishedEvent {
|
||||
id: "game".to_owned(),
|
||||
peer_id: "peer".to_owned(),
|
||||
peer_addr: "127.0.0.1:1".parse().expect("test address should parse"),
|
||||
content_id: "content".to_owned(),
|
||||
relative_path: "game.eti".to_owned(),
|
||||
offset: 0,
|
||||
length: 64,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let (_, current_terminal) =
|
||||
download_terminal_event(&shared, "download-finished", newer, None).await;
|
||||
assert_eq!(current_terminal["attempt_id"], "2");
|
||||
assert_eq!(current_terminal["throughput"]["bytes"], 64);
|
||||
assert_eq!(current_terminal["throughput"]["chunks"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn download_activity_json_is_attempt_keyed_and_typed() {
|
||||
let shared = shared_state(PathBuf::from("games"));
|
||||
let attempt = download_attempt("game", 42);
|
||||
|
||||
let (event, data) = update_state_from_event(
|
||||
&shared,
|
||||
PeerEvent::DownloadGameFilesActivityChanged {
|
||||
attempt,
|
||||
activity: Some(lanspread_peer::DownloadVerificationActivity::RetryingInvalidSource),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(event, "download-activity-changed");
|
||||
assert_eq!(data["game_id"], "game");
|
||||
assert_eq!(data["attempt_id"], "42");
|
||||
assert_eq!(data["activity"], "retrying-invalid-source");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_game_dir_stores_and_returns_only_the_acknowledged_path() {
|
||||
let shared = shared_state(PathBuf::from("previous-games"));
|
||||
let accepted = PathBuf::from("canonical-games");
|
||||
let (sender, mut commands) = mpsc::unbounded_channel();
|
||||
|
||||
let request = set_game_dir(&sender, &shared, Path::new("alias-games"));
|
||||
let reply = async {
|
||||
let Some(PeerCommand::SetGameDir { path, reply }) = commands.recv().await else {
|
||||
panic!("SetGameDir command should carry a reply");
|
||||
};
|
||||
assert_eq!(path, Path::new("alias-games"));
|
||||
reply
|
||||
.send(Ok(accepted.clone()))
|
||||
.expect("SetGameDir requester should retain its reply receiver");
|
||||
};
|
||||
let (result, ()) = tokio::join!(request, reply);
|
||||
|
||||
assert_eq!(
|
||||
result.expect("acknowledged directory should succeed"),
|
||||
json!({"accepted": true, "path": accepted})
|
||||
);
|
||||
assert_eq!(*shared.games_dir.read().await, accepted);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_game_dir_rejection_preserves_the_previous_path() {
|
||||
let previous = PathBuf::from("previous-games");
|
||||
let shared = shared_state(previous.clone());
|
||||
let (sender, mut commands) = mpsc::unbounded_channel();
|
||||
|
||||
let request = set_game_dir(&sender, &shared, Path::new("rejected-games"));
|
||||
let reply = async {
|
||||
let Some(PeerCommand::SetGameDir { reply, .. }) = commands.recv().await else {
|
||||
panic!("SetGameDir command should carry a reply");
|
||||
};
|
||||
reply
|
||||
.send(Err("operations are active".to_string()))
|
||||
.expect("SetGameDir requester should retain its reply receiver");
|
||||
};
|
||||
let (result, ()) = tokio::join!(request, reply);
|
||||
|
||||
assert!(
|
||||
result
|
||||
.expect_err("rejected directory should fail")
|
||||
.to_string()
|
||||
.contains("operations are active")
|
||||
);
|
||||
assert_eq!(*shared.games_dir.read().await, previous);
|
||||
}
|
||||
|
||||
async fn assert_abnormal_command_finalizes(
|
||||
command_task: JoinHandle<eyre::Result<CommandLoopExit>>,
|
||||
) -> eyre::Report {
|
||||
let event_stop = tokio_util::sync::CancellationToken::new();
|
||||
let shutdown_ran = Arc::new(AtomicBool::new(false));
|
||||
let event_finished = Arc::new(AtomicBool::new(false));
|
||||
let event_task = tokio::spawn({
|
||||
let event_stop = event_stop.clone();
|
||||
let shutdown_ran = shutdown_ran.clone();
|
||||
let event_finished = event_finished.clone();
|
||||
async move {
|
||||
event_stop.cancelled().await;
|
||||
assert!(
|
||||
shutdown_ran.load(Ordering::SeqCst),
|
||||
"the runtime finalizer must release the event loop"
|
||||
);
|
||||
event_finished.store(true, Ordering::SeqCst);
|
||||
}
|
||||
});
|
||||
let shutdown = {
|
||||
let shutdown_ran = shutdown_ran.clone();
|
||||
async move {
|
||||
shutdown_ran.store(true, Ordering::SeqCst);
|
||||
event_stop.cancel();
|
||||
}
|
||||
};
|
||||
|
||||
let signal = std::future::pending::<eyre::Result<()>>();
|
||||
let error = join_cli_scope(command_task, event_task, signal, shutdown)
|
||||
.await
|
||||
.expect_err("an abnormal command task must fail the CLI scope");
|
||||
|
||||
assert!(shutdown_ran.load(Ordering::SeqCst));
|
||||
assert!(
|
||||
event_finished.load(Ordering::SeqCst),
|
||||
"the CLI scope must join its event task before returning"
|
||||
);
|
||||
error
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn command_loop_panic_still_finalizes_runtime_and_event_loop() {
|
||||
async fn panic_command_loop() -> eyre::Result<CommandLoopExit> {
|
||||
tokio::task::yield_now().await;
|
||||
panic!("injected command-loop panic");
|
||||
}
|
||||
let command_task = tokio::spawn(panic_command_loop());
|
||||
|
||||
let error = assert_abnormal_command_finalizes(command_task).await;
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("command loop task failed to join")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn command_loop_cancellation_still_finalizes_runtime_and_event_loop() {
|
||||
let command_task = tokio::spawn(std::future::pending::<eyre::Result<CommandLoopExit>>());
|
||||
command_task.abort();
|
||||
|
||||
let error = assert_abnormal_command_finalizes(command_task).await;
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("command loop task failed to join")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn regular_file_commands_are_preloaded_before_runtime_ownership() {
|
||||
let path = temp_file_path("regular-stdin");
|
||||
std::fs::write(&path, b"first\nsecond\n").expect("should create redirected stdin file");
|
||||
let file = std::fs::File::open(&path).expect("should reopen redirected stdin file");
|
||||
|
||||
let input = CommandInput::from_unix_file(file).expect("regular input should preload");
|
||||
assert!(matches!(&input, CommandInput::Buffered(_)));
|
||||
let mut lines = BufReader::new(input).lines();
|
||||
assert_eq!(
|
||||
lines.next_line().await.expect("first line should decode"),
|
||||
Some("first".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
lines.next_line().await.expect("second line should decode"),
|
||||
Some("second".to_string())
|
||||
);
|
||||
assert_eq!(lines.next_line().await.expect("EOF should decode"), None);
|
||||
|
||||
std::fs::remove_file(path).expect("should remove redirected stdin fixture");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn pending_pollable_stdin_is_quiescent_before_exceptional_exit_policy() {
|
||||
let (read_end, _write_end) =
|
||||
std::os::unix::net::UnixStream::pair().expect("should create pending stdin socket");
|
||||
let poll_started = Arc::new(Notify::new());
|
||||
let file = std::fs::File::from(OwnedFd::from(read_end));
|
||||
let stdin = UnixStdin::new(file)
|
||||
.expect("socket should be pollable")
|
||||
.with_poll_started(poll_started.clone());
|
||||
let input = CommandInput::Pollable(stdin);
|
||||
let (sender, _commands) = mpsc::unbounded_channel();
|
||||
let command_task = tokio::spawn(command_loop(
|
||||
input,
|
||||
sender,
|
||||
Arc::new(shared_state(PathBuf::from("games"))),
|
||||
JsonlWriter::new(),
|
||||
));
|
||||
let event_task = tokio::spawn(async move {
|
||||
poll_started.notified().await;
|
||||
});
|
||||
let shutdown_ran = Arc::new(AtomicBool::new(false));
|
||||
let shutdown = {
|
||||
let shutdown_ran = shutdown_ran.clone();
|
||||
async move {
|
||||
shutdown_ran.store(true, Ordering::SeqCst);
|
||||
}
|
||||
};
|
||||
|
||||
let outcome = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
join_cli_scope(
|
||||
command_task,
|
||||
event_task,
|
||||
std::future::pending::<eyre::Result<()>>(),
|
||||
shutdown,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("pollable stdin cancellation must not hang")
|
||||
.expect("event-first settlement should produce a scope outcome");
|
||||
assert!(shutdown_ran.load(Ordering::SeqCst));
|
||||
|
||||
let process_boundary_called = Arc::new(AtomicBool::new(false));
|
||||
let error = finish_cli_scope(outcome, false, {
|
||||
let process_boundary_called = process_boundary_called.clone();
|
||||
move |exit_code, failure| {
|
||||
assert!(shutdown_ran.load(Ordering::SeqCst));
|
||||
assert_eq!(exit_code, 1);
|
||||
assert!(failure.is_some());
|
||||
process_boundary_called.store(true, Ordering::SeqCst);
|
||||
eyre::bail!("injected process boundary")
|
||||
}
|
||||
})
|
||||
.expect_err("the injected process boundary should return its error");
|
||||
assert_eq!(error.to_string(), "injected process boundary");
|
||||
assert!(process_boundary_called.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
struct DropFlag(Arc<AtomicBool>);
|
||||
|
||||
impl Drop for DropFlag {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn injected_signal_settles_command_peer_and_events_before_returning() {
|
||||
let command_started = Arc::new(Notify::new());
|
||||
let command_dropped = Arc::new(AtomicBool::new(false));
|
||||
let command_task = tokio::spawn({
|
||||
let command_started = command_started.clone();
|
||||
let command_dropped = command_dropped.clone();
|
||||
async move {
|
||||
let _drop_flag = DropFlag(command_dropped);
|
||||
command_started.notify_one();
|
||||
std::future::pending::<()>().await;
|
||||
Ok(CommandLoopExit::Eof)
|
||||
}
|
||||
});
|
||||
let event_stop = tokio_util::sync::CancellationToken::new();
|
||||
let event_finished = Arc::new(AtomicBool::new(false));
|
||||
let event_task = tokio::spawn({
|
||||
let event_stop = event_stop.clone();
|
||||
let event_finished = event_finished.clone();
|
||||
async move {
|
||||
event_stop.cancelled().await;
|
||||
event_finished.store(true, Ordering::SeqCst);
|
||||
}
|
||||
});
|
||||
let shutdown_ran = Arc::new(AtomicBool::new(false));
|
||||
let shutdown = {
|
||||
let shutdown_ran = shutdown_ran.clone();
|
||||
async move {
|
||||
shutdown_ran.store(true, Ordering::SeqCst);
|
||||
event_stop.cancel();
|
||||
}
|
||||
};
|
||||
let (send_signal, receive_signal) = oneshot::channel();
|
||||
let signal = async move {
|
||||
receive_signal
|
||||
.await
|
||||
.wrap_err("injected signal sender disappeared")
|
||||
};
|
||||
let scope = join_cli_scope(command_task, event_task, signal, shutdown);
|
||||
let trigger = async move {
|
||||
command_started.notified().await;
|
||||
send_signal
|
||||
.send(())
|
||||
.expect("scope should retain its injected signal receiver");
|
||||
};
|
||||
let (outcome, ()) = tokio::join!(scope, trigger);
|
||||
let outcome = outcome.expect("injected signal should settle cleanly");
|
||||
|
||||
assert!(command_dropped.load(Ordering::SeqCst));
|
||||
assert!(shutdown_ran.load(Ordering::SeqCst));
|
||||
assert!(event_finished.load(Ordering::SeqCst));
|
||||
let process_boundary_called = Arc::new(AtomicBool::new(false));
|
||||
let exit = finish_cli_scope(outcome, true, {
|
||||
let process_boundary_called = process_boundary_called.clone();
|
||||
move |_, _| {
|
||||
process_boundary_called.store(true, Ordering::SeqCst);
|
||||
eyre::bail!("signal should not require a Unix process boundary")
|
||||
}
|
||||
})
|
||||
.expect("an injected signal should become a clean CLI exit");
|
||||
assert!(matches!(exit, CommandLoopExit::Signal));
|
||||
assert!(!process_boundary_called.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_command_exits_never_take_the_process_boundary() {
|
||||
let process_boundary_called = Arc::new(AtomicBool::new(false));
|
||||
for exit in [
|
||||
CommandLoopExit::Eof,
|
||||
CommandLoopExit::Shutdown {
|
||||
request_id: Some(json!(42)),
|
||||
},
|
||||
] {
|
||||
finish_cli_scope(CliScopeOutcome::Command(exit), false, {
|
||||
let process_boundary_called = process_boundary_called.clone();
|
||||
move |_, _| {
|
||||
process_boundary_called.store(true, Ordering::SeqCst);
|
||||
eyre::bail!("normal command exit must not terminate the process")
|
||||
}
|
||||
})
|
||||
.expect("normal command exit should remain ordinary");
|
||||
}
|
||||
assert!(!process_boundary_called.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
const SIGNAL_HELPER_ENV: &str = "LANSPREAD_TEST_SIGTERM_SCOPE_HELPER";
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn unix_sigterm_scope_helper() {
|
||||
if std::env::var_os(SIGNAL_HELPER_ENV).is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
let input = CommandInput::open().expect("helper stdin should be pollable");
|
||||
let (sender, _commands) = mpsc::unbounded_channel();
|
||||
let command_task = tokio::spawn(command_loop(
|
||||
input,
|
||||
sender,
|
||||
Arc::new(shared_state(PathBuf::from("games"))),
|
||||
JsonlWriter::new(),
|
||||
));
|
||||
let event_stop = tokio_util::sync::CancellationToken::new();
|
||||
let event_task = tokio::spawn({
|
||||
let event_stop = event_stop.clone();
|
||||
async move {
|
||||
event_stop.cancelled().await;
|
||||
}
|
||||
});
|
||||
let shutdown = async move {
|
||||
event_stop.cancel();
|
||||
};
|
||||
let scope_task = tokio::spawn(join_cli_scope(
|
||||
command_task,
|
||||
event_task,
|
||||
shutdown_signal(),
|
||||
shutdown,
|
||||
));
|
||||
tokio::task::yield_now().await;
|
||||
println!("SIGNAL-SCOPE-READY");
|
||||
std::io::stdout()
|
||||
.flush()
|
||||
.expect("helper readiness should flush");
|
||||
|
||||
let outcome = tokio::time::timeout(Duration::from_secs(5), scope_task)
|
||||
.await
|
||||
.expect("SIGTERM scope should not hang")
|
||||
.expect("SIGTERM scope task should join")
|
||||
.expect("SIGTERM scope should settle cleanly");
|
||||
let exit = finish_cli_scope(outcome, true, |_, _| {
|
||||
eyre::bail!("Unix SIGTERM must not need process termination")
|
||||
})
|
||||
.expect("SIGTERM should become a clean CLI exit");
|
||||
assert!(matches!(exit, CommandLoopExit::Signal));
|
||||
println!("SIGNAL-SCOPE-SETTLED");
|
||||
std::io::stdout()
|
||||
.flush()
|
||||
.expect("helper settlement should flush");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn real_sigterm_settles_scope_while_stdin_remains_open() {
|
||||
let executable = std::env::current_exe().expect("test executable path should exist");
|
||||
let mut command = tokio::process::Command::new(executable);
|
||||
command
|
||||
.arg("--exact")
|
||||
.arg("tests::unix_sigterm_scope_helper")
|
||||
.arg("--nocapture")
|
||||
.env(SIGNAL_HELPER_ENV, "1")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
let mut child = command.spawn().expect("SIGTERM helper should spawn");
|
||||
let _open_stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.expect("SIGTERM helper stdin should remain open");
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.expect("SIGTERM helper stdout should be piped");
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
|
||||
loop {
|
||||
let line = tokio::time::timeout(Duration::from_secs(5), lines.next_line())
|
||||
.await
|
||||
.expect("SIGTERM helper readiness should not time out")
|
||||
.expect("SIGTERM helper stdout should be readable")
|
||||
.expect("SIGTERM helper should announce readiness");
|
||||
if line.contains("SIGNAL-SCOPE-READY") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let raw_pid = i32::try_from(child.id().expect("SIGTERM helper should have a PID"))
|
||||
.expect("SIGTERM helper PID should fit a Unix pid_t");
|
||||
let pid =
|
||||
rustix::process::Pid::from_raw(raw_pid).expect("SIGTERM helper PID should be nonzero");
|
||||
rustix::process::kill_process(pid, rustix::process::Signal::TERM)
|
||||
.expect("SIGTERM should be delivered to helper");
|
||||
|
||||
let mut settled = false;
|
||||
while let Some(line) = tokio::time::timeout(Duration::from_secs(5), lines.next_line())
|
||||
.await
|
||||
.expect("SIGTERM helper settlement should not time out")
|
||||
.expect("SIGTERM helper stdout should remain readable")
|
||||
{
|
||||
if line.contains("SIGNAL-SCOPE-SETTLED") {
|
||||
settled = true;
|
||||
}
|
||||
}
|
||||
let status = tokio::time::timeout(Duration::from_secs(5), child.wait())
|
||||
.await
|
||||
.expect("SIGTERM helper process should not hang")
|
||||
.expect("SIGTERM helper process should be waitable");
|
||||
assert!(status.success(), "SIGTERM helper failed with {status}");
|
||||
assert!(settled, "SIGTERM helper must report settled ownership");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user