mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-10-30 04:49:32 -04:00
Windows: build and unit-test the daemon and CLI on a Windows runner, fix what it found, publish Windows nightlies (#3148)
* ci: build and unit-test sd-core and sd-cli on a Windows runner The job is the first Windows build since the rewrite and the evidence for the cross-platform release gate. It runs on GitHub's hosted windows-latest (Blacksmith has no Windows), builds sd-cli and sd-daemon in debug, then runs the sd-core lib tests and the sd-cli tests. continue-on-error keeps it from gating main until it has been green twice. Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> * cli: name the daemon binary and replace binaries the way Windows allows The CLI built the daemon's path as sd-daemon everywhere, which does not exist on Windows; sd_client::daemon_binary_name appends the platform's executable suffix. sd update renamed the staged file over the running binary, which Windows refuses for a loaded executable: it now moves the current binary to .bak first and renames the new one into place, removing the backup where the platform lets it and on the next update otherwise. Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> * indexing: keep the hidden bit in the arena instead of re-reading it on every read The arena rebuilt is_hidden from the path on every entry read. On Unix that is a name check; on Windows it is a GetFileAttributesW call per entry, and it answers false for anything not on disk at that moment, so search returned hidden files from a snapshot or a detached volume. PackedMetadata now carries a hidden bit (size drops to 59 bits), the arena records what the walk saw, and the writer judges a change by the metadata it carries. Snapshot restore recomputes the bit from names where names decide. Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> * volume: tell a Windows mount point from the directory a drive left behind is_mount_point answered by existence on Windows, so the empty folder an unplugged mapped drive leaves passed as mounted and the detached volume's source could be dispatched at it. GetVolumePathNameW names the mount point of the volume holding a path; a directory that is its own volume root is a mount point, anything else resolves to a parent volume. Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> * core: let three path-shaped unit tests read the same on Windows The compare and media tests compared displayed paths against slash-written expectations, and the track action test offered a Unix absolute path that Windows reads as relative. The tests now normalise separators and pick an absolute root the platform accepts; what they assert is unchanged. Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> * files: delete a moved source when the filesystem still has it, not when the volume index guesses After a streaming copy in move mode the source was deleted only when the volume manager said the two paths were on different volumes. On Windows it answered same-volume for a copy the router had already decided to stream, so the file stayed in both places and the move reported success. The job now checks whether the source still exists after the strategy ran and removes it then, which is the condition the comment described. Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> * ci: save the Windows rust cache on a red run too Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> * ci: publish Windows x86_64 nightly binaries The nightly matrix gains x86_64-pc-windows-msvc on windows-latest, producing sd-windows-x86_64.exe and sd-daemon-windows-x86_64.exe with checksums. The build and prepare steps run under bash on every host, and the asset names carry .exe, which the updater already strips when matching. Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> * docs: Windows gap report and register row docs/plans/2026-10-09-windows-gap.md records the first Windows build since the rewrite: what compiled, what passed, each failure with its cause and fix, what the integration, FDA, R8 and locked volumes suites need to run on Windows, and the beta.1 recommendation with release notes text. Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> * review: judge mount points on the path as given, mark placeholder directories hidden, keep the rename-over update on Unix GetVolumePathNameW judges the string it is handed, so a subst or mapped drive letter must not be canonicalized to its target first or its own root reads as not a mount point. Arena::ensure_directory creates directory placeholders the walk has not statted; they now carry the hidden answer the path gives until the walk corrects them. The updater moves the running binary aside only on Windows, which needs it; elsewhere a .bak copy and a rename over the target keep a working file at the path at every instant. Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
3234ab28a7
commit
15d7df1808
18 files changed
+461
-75
No files matched your search
@@ -138,3 +138,54 @@ jobs:
|
||||
|
||||
- name: Run the ZFS locked volumes suite
|
||||
run: RUST_LOG=sd_core=info cargo test -p sd-core --test zfs_locked_volumes_acceptance_test -- --test-threads=1 --nocapture
|
||||
|
||||
# Windows is the one supported desktop platform the rewrite has never been
|
||||
# built on, so this job is the evidence for the cross-platform gate in
|
||||
# docs/core/releases.mdx. It runs on GitHub's hosted image because
|
||||
# Blacksmith has no Windows runners. The sd-core lib tests and the sd-cli
|
||||
# tests are the cheap half; the integration and acceptance suites assume a
|
||||
# Linux filesystem and are tracked in docs/plans/2026-10-09-windows-gap.md.
|
||||
# continue-on-error keeps the job from gating main until it has been green
|
||||
# twice; drop it then.
|
||||
windows:
|
||||
name: Core Tests (windows unit, continue-on-error until green twice)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 60
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
env:
|
||||
# PDBs for every test binary and their link time are most of a Windows
|
||||
# debug build; the tests do not need symbols.
|
||||
CARGO_PROFILE_DEV_DEBUG: 0
|
||||
CARGO_PROFILE_TEST_DEBUG: 0
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# The hosted image has no rustup toolchain for the pinned channel, so
|
||||
# setup-rust installs it from rust-toolchain.toml.
|
||||
- name: Setup Rust
|
||||
uses: ./.github/actions/setup-rust
|
||||
with:
|
||||
restore-cache: "false"
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: core-tests-windows
|
||||
# The job is expected to be red while the gaps are worked through;
|
||||
# a cache saved only on success would never warm.
|
||||
cache-on-failure: "true"
|
||||
|
||||
- name: Build sd-cli and sd-daemon
|
||||
run: cargo build --locked -p sd-cli -p sd-core --bin sd-cli --bin sd-daemon
|
||||
|
||||
- name: Run sd-core unit tests
|
||||
run: cargo test --locked -p sd-core --lib
|
||||
|
||||
- name: Run sd-cli tests
|
||||
run: cargo test --locked -p sd-cli
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
# These binaries are not signed with a Developer ID and are not notarized. The
|
||||
# macOS linker adds an ad-hoc signature, which is enough to run a file the CLI
|
||||
# downloaded itself; a copy fetched through a browser carries the quarantine
|
||||
# attribute and Gatekeeper refuses it until that attribute is removed.
|
||||
# attribute and Gatekeeper refuses it until that attribute is removed. The
|
||||
# Windows binaries carry no Authenticode signature, so SmartScreen warns on the
|
||||
# first run of a browser download; `sd update` replaces binaries in place and
|
||||
# is not affected.
|
||||
#
|
||||
# The binaries are built with sd-core's default features: no bundled FFmpeg,
|
||||
# libheif or Pdfium, so they do not depend on apps/.deps at runtime. macOS
|
||||
@@ -52,6 +55,11 @@ jobs:
|
||||
- host: blacksmith-4vcpu-ubuntu-2204
|
||||
target: x86_64-unknown-linux-gnu
|
||||
platform: linux-x86_64
|
||||
# GitHub-hosted: Blacksmith has no Windows runners. Unsigned like
|
||||
# the others; SmartScreen warns on first run of a browser download.
|
||||
- host: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
platform: windows-x86_64
|
||||
name: Build ${{ matrix.settings.platform }}
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
timeout-minutes: 90
|
||||
@@ -83,6 +91,7 @@ jobs:
|
||||
./scripts/setup.sh
|
||||
|
||||
- name: Build sd-cli and sd-daemon
|
||||
shell: bash
|
||||
env:
|
||||
SD_GIT_SHA: ${{ github.sha }}
|
||||
OPENSSL_STATIC: '1'
|
||||
@@ -92,18 +101,21 @@ jobs:
|
||||
-p sd-cli -p sd-core --bin sd-cli --bin sd-daemon \
|
||||
--target ${{ matrix.settings.target }}
|
||||
|
||||
# Names are <binary>-<platform>; the updater strips the platform and
|
||||
# expects exactly `sd` and `sd-daemon`.
|
||||
# Names are <binary>-<platform>, plus .exe on Windows; the updater
|
||||
# strips both and expects exactly `sd` and `sd-daemon`.
|
||||
- name: Prepare binaries and checksums
|
||||
shell: bash
|
||||
run: |
|
||||
set -eux
|
||||
mkdir -p dist
|
||||
platform=${{ matrix.settings.platform }}
|
||||
cp target/${{ matrix.settings.target }}/release/sd-cli "dist/sd-$platform"
|
||||
cp target/${{ matrix.settings.target }}/release/sd-daemon "dist/sd-daemon-$platform"
|
||||
exe=""
|
||||
if [ "${{ runner.os }}" = "Windows" ]; then exe=".exe"; fi
|
||||
cp "target/${{ matrix.settings.target }}/release/sd-cli$exe" "dist/sd-$platform$exe"
|
||||
cp "target/${{ matrix.settings.target }}/release/sd-daemon$exe" "dist/sd-daemon-$platform$exe"
|
||||
chmod +x dist/*
|
||||
cd dist
|
||||
for f in "sd-$platform" "sd-daemon-$platform"; do
|
||||
for f in "sd-$platform$exe" "sd-daemon-$platform$exe"; do
|
||||
if command -v sha256sum >/dev/null; then
|
||||
sha256sum "$f" > "$f.sha256"
|
||||
else
|
||||
@@ -111,7 +123,7 @@ jobs:
|
||||
fi
|
||||
done
|
||||
cat ./*.sha256
|
||||
"./sd-$platform" --version
|
||||
"./sd-$platform$exe" --version
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# Project Status
|
||||
|
||||
> Last updated: 2026-10-08
|
||||
> Last updated: 2026-10-09
|
||||
> Code baseline: `b1a58be`
|
||||
> Branch of record: `main` (fast-forwarded from `sources` on 2026-10-05)
|
||||
> Purpose: short-term working context across development sessions while
|
||||
@@ -219,6 +219,7 @@ Recent landed.
|
||||
| MCP server over the registry | PR open 2026-10-07 (Fable, SPAC-33); one PR against `main` | Fable | Product sequence step 2, second half. `sd mcp` speaks MCP over stdio with rmcp and exposes every registered query, action, validate and preview as a tool named after the op, with the input schema walked from the op's specta input type (`core/src/infra/wire/json_schema.rs`), `library_id` on library ops defaulting to the CLI's selection when the daemon still lists it, else the first library, read-only annotations on queries, daemon errors as tool errors, and a synthetic `jobs.wait` that polls `jobs.info` to a terminal status. The server holds no state and no auth beyond the daemon socket. `apps/cli/tests/mcp_test.rs` (acceptance group) starts a core and RPC server in-process, spawns `sd-cli mcp`, handshakes, lists tools, calls `libraries.list` and `jobs.list`, tracks a source and waits for its walk. Docs: `docs/cli/mcp.mdx` (Claude Code, Cursor, Capy configs) and the CLI skill. Next: try it from Claude Code against the MacBook daemon; decide whether actions should require a confirmed preview. |
|
||||
| Repository release gates in CI | in progress 2026-10-07 (Fable, SPAC-26); one PR against `main` | Fable | `docs/core/releases.mdx` lists five repository gates; only formatting, clippy, typecheck and tests had jobs. `ci.yml` gains four more on every PR to `main`: Docs (bun 1.3.4, `docs/` frozen lockfile, `types:check` and `next build`), Generated types (`generate_typescript_types` plus `cargo check -p spacedrive` for the Tauri schemas, fails on a diff under `packages/ts-client/src/generated` or `apps/tauri/src-tauri/gen/schemas`, own rust-cache key), Secrets scan (pinned gitleaks over the PR's commits and the tree, allowlist in `.gitleaks.toml` including the AWS docs example key in `cloud_credentials.rs`), and License and notices (LICENSE is Apache-2.0, NOTICE exists and carries the same AGPL-3.0 cut-over date as README). Found and fixed on the way: the docs build failed on an unescaped `<name>` in `cli/library-sync-setup.mdx`, and the committed Tauri schemas predated the updater plugin. The gates in `releases.mdx` now name their job. Next: confirm the four jobs green on the PR and the warm wall time under 10 min. |
|
||||
| Library migrations on one pinned SQLite connection | in PR 2026-10-08 (Fable, SPAC-48); one PR against `main` | Fable | `create_library` failed its fresh migration with `no such column: "device_id"` about 1 in 100 under CPU oversubscription (SPAC-34, SPAC-46). sea-orm-migration wraps the run in a transaction for Postgres only, so on SQLite every statement went to the pool, whose idle queue is FIFO: statements rotate round-robin over the open connections, each with its own cached schema. A connection that is skipped for the 14 statements between `m20251226`'s `ADD COLUMN device_id` and `m20260104`'s `DROP COLUMN device_id` still caches `entries` without the column, and SQLite resolves a DROP COLUMN at prepare time against that cache without the SQLITE_SCHEMA retry other statements get, so it fails for good. Deterministic with 15 or more eagerly opened connections (production allows 30); a pool of 2 never fails. `Database::migrate` now runs `Migrator::up` inside one transaction on one connection; `infra::db::tests::migrate_fresh_database_on_eagerly_filled_pool` migrates a fresh library on a 30-connection pool and failed every run on the old code. `sync.db` runs only `CREATE ... IF NOT EXISTS` statements, which re-prepare on a stale cache, and the store crate (`crates/store/src/migrate.rs`) already pins each migration to one acquired connection in a transaction, so neither needed a change. |
|
||||
| Windows build and unit suites | in PR 2026-10-09 (Fable, SPAC-49); gap doc written | Fable | Plan: `docs/plans/2026-10-09-windows-gap.md`. First Windows build since the rewrite: `core_tests.yml` gains `Core Tests (windows unit)` on GitHub-hosted `windows-latest` (Blacksmith has no Windows), building `sd-cli` and `sd-daemon` in debug and running `cargo test -p sd-core --lib` and `-p sd-cli`; `continue-on-error` until green twice on `main`. At `03efcad` everything compiled unchanged and 605 of 613 lib tests passed; the eight failures were three Windows bugs (a streaming move left its source because the delete was gated on volume knowledge; the arena re-read hidden through `GetFileAttributesW` on every entry read so hidden files leaked into search; `is_mount_point` was an existence stub so an unplugged drive's leftover folder passed as mounted) and five Unix-shaped tests, all fixed; the suite is green on the second run. `sd update` names `sd-daemon.exe` and moves the running binary aside before the rename, and nightly publishes `sd-windows-x86_64.exe` and `sd-daemon-windows-x86_64.exe`. Not run on Windows: the integration suites, the FDA and R8 matrices, the locked volumes suite, and `sd-fs-watcher`; the plan sizes each. Recommendation: Windows out of beta.1 with the nightly CLI as a preview; release notes text is in the plan. Next: a warm-cache timing of the job (52 min cold), then drop `continue-on-error` after two green runs on `main`. |
|
||||
|
||||
## Product sequence
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ async fn install_launchd_service(data_dir: PathBuf, instance: Option<String>) ->
|
||||
let daemon_path = current_exe
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("Could not determine binary directory"))?
|
||||
.join("sd-daemon");
|
||||
.join(sd_client::daemon_binary_name());
|
||||
|
||||
if !daemon_path.exists() {
|
||||
return Err(anyhow::anyhow!(
|
||||
@@ -271,7 +271,7 @@ async fn install_launchd_service(data_dir: PathBuf, instance: Option<String>) ->
|
||||
let daemon_path = current_exe
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("Could not determine binary directory"))?
|
||||
.join("sd-daemon");
|
||||
.join(sd_client::daemon_binary_name());
|
||||
|
||||
if !daemon_path.exists() {
|
||||
return Err(anyhow::anyhow!(
|
||||
|
||||
@@ -311,7 +311,7 @@ async fn attempt(
|
||||
let bin_dir = current_exe
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("Could not determine binary directory"))?;
|
||||
let daemon_path = bin_dir.join("sd-daemon");
|
||||
let daemon_path = bin_dir.join(sd_client::daemon_binary_name());
|
||||
|
||||
println!();
|
||||
println!("Installing updates...");
|
||||
@@ -725,9 +725,16 @@ fn discard_other_parts(part_dir: &Path, build_key: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces `path` atomically: the new bytes land in a sibling temp file that
|
||||
/// is renamed over the target, so a running binary is never truncated or
|
||||
/// partially overwritten.
|
||||
/// Replaces `path` with `data` without ever truncating the running binary.
|
||||
///
|
||||
/// The new bytes land in a sibling `.update` file and are renamed into place.
|
||||
/// Where the platform allows a rename over a running executable (Unix), the
|
||||
/// current binary is first copied to `.bak` so a kill at any instant still
|
||||
/// leaves a working file at the path. Windows lets a loaded executable be
|
||||
/// renamed but not replaced or deleted, so there the current binary is moved
|
||||
/// to `.bak` before the staged file takes its place, the path is briefly
|
||||
/// empty, and the `.bak` of the running `sd` stays behind until the next
|
||||
/// update removes it. A failed install restores the backup either way.
|
||||
fn replace_binary(path: &Path, data: &[u8]) -> Result<()> {
|
||||
use std::fs;
|
||||
|
||||
@@ -737,6 +744,7 @@ fn replace_binary(path: &Path, data: &[u8]) -> Result<()> {
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid binary path: {}", path.display()))?;
|
||||
let staged = path.with_file_name(format!("{}.update", file_name));
|
||||
let backup = path.with_file_name(format!("{}.bak", file_name));
|
||||
let _ = fs::remove_file(&backup);
|
||||
|
||||
fs::write(&staged, data).with_context(|| format!("writing {}", staged.display()))?;
|
||||
|
||||
@@ -746,8 +754,14 @@ fn replace_binary(path: &Path, data: &[u8]) -> Result<()> {
|
||||
fs::set_permissions(&staged, fs::Permissions::from_mode(0o755))?;
|
||||
}
|
||||
|
||||
if path.exists() {
|
||||
fs::copy(path, &backup)?;
|
||||
let had_previous = path.exists();
|
||||
if had_previous {
|
||||
if cfg!(windows) {
|
||||
fs::rename(path, &backup)
|
||||
.with_context(|| format!("moving {} aside", path.display()))?;
|
||||
} else {
|
||||
fs::copy(path, &backup).with_context(|| format!("backing up {}", path.display()))?;
|
||||
}
|
||||
}
|
||||
|
||||
match fs::rename(&staged, path) {
|
||||
@@ -756,7 +770,7 @@ fn replace_binary(path: &Path, data: &[u8]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
if backup.exists() {
|
||||
if had_previous {
|
||||
let _ = fs::rename(&backup, path);
|
||||
}
|
||||
let _ = fs::remove_file(&staged);
|
||||
@@ -858,6 +872,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_binary_swaps_the_file_and_leaves_no_backup() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let name = format!("sd{}", std::env::consts::EXE_SUFFIX);
|
||||
let path = dir.path().join(&name);
|
||||
std::fs::write(&path, b"old").unwrap();
|
||||
std::fs::write(dir.path().join(format!("{name}.bak")), b"stale").unwrap();
|
||||
|
||||
replace_binary(&path, b"new").unwrap();
|
||||
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
let leftovers: Vec<_> = std::fs::read_dir(dir.path())
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name())
|
||||
.collect();
|
||||
assert_eq!(leftovers, vec![std::ffi::OsString::from(name)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sd_and_daemon_resolve_to_distinct_assets() {
|
||||
let assets = vec![
|
||||
|
||||
@@ -292,7 +292,10 @@ async fn main() -> Result<()> {
|
||||
let client = CoreClient::new(socket_addr.clone());
|
||||
let current_exe = std::env::current_exe()?;
|
||||
let launch = DaemonLaunchConfig {
|
||||
daemon_path: current_exe.parent().unwrap().join("sd-daemon"),
|
||||
daemon_path: current_exe
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join(sd_client::daemon_binary_name()),
|
||||
data_dir: data_dir.clone(),
|
||||
instance: instance.clone(),
|
||||
no_default_sources,
|
||||
@@ -422,7 +425,10 @@ async fn main() -> Result<()> {
|
||||
println!("Starting daemon...");
|
||||
let current_exe = std::env::current_exe()?;
|
||||
let launch = DaemonLaunchConfig {
|
||||
daemon_path: current_exe.parent().unwrap().join("sd-daemon"),
|
||||
daemon_path: current_exe
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join(sd_client::daemon_binary_name()),
|
||||
data_dir: data_dir.clone(),
|
||||
instance: instance.clone(),
|
||||
no_default_sources,
|
||||
|
||||
@@ -679,39 +679,37 @@ impl JobHandler for FileCopyJob {
|
||||
super::metadata::CopyFileStatus::Completed,
|
||||
);
|
||||
|
||||
// If this is a move operation and the strategy didn't handle deletion,
|
||||
// we need to delete the source after successful copy
|
||||
// A move whose strategy copied rather than renamed still has
|
||||
// its source. Whether the rename happened is read from the
|
||||
// filesystem, not inferred from volume knowledge: the volume
|
||||
// index can know less than the router did (it did on Windows,
|
||||
// where a same-volume guess here skipped the delete after a
|
||||
// streaming copy and left the file in both places).
|
||||
if is_move && resolved_source.device_slug() == final_destination.device_slug() {
|
||||
// For same-device moves, LocalMoveStrategy handles deletion atomically
|
||||
// For cross-volume moves, LocalStreamCopyStrategy needs manual deletion
|
||||
if let Some(vm) = volume_manager.as_deref() {
|
||||
if let (Some(source_path), Some(dest_path)) = (
|
||||
resolved_source.as_local_path(),
|
||||
final_destination.as_local_path(),
|
||||
) {
|
||||
if !vm.same_volume(source_path, dest_path).await {
|
||||
// Cross-volume move - delete source
|
||||
if let Err(e) = self.delete_source_file(source_path).await {
|
||||
failed_copies.push(CopyError {
|
||||
source: resolved_source
|
||||
.path()
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
destination: final_destination
|
||||
.path()
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
error: format!(
|
||||
"Copy succeeded but failed to delete source: {}",
|
||||
e
|
||||
),
|
||||
});
|
||||
ctx.add_non_critical_error(format!(
|
||||
"Failed to delete source after move {}: {}",
|
||||
resolved_source.display(),
|
||||
if let (Some(source_path), Some(dest_path)) = (
|
||||
resolved_source.as_local_path(),
|
||||
final_destination.as_local_path(),
|
||||
) {
|
||||
let still_there =
|
||||
tokio::fs::symlink_metadata(source_path).await.is_ok();
|
||||
if still_there && source_path != dest_path {
|
||||
if let Err(e) = self.delete_source_file(source_path).await {
|
||||
failed_copies.push(CopyError {
|
||||
source: resolved_source.path().cloned().unwrap_or_default(),
|
||||
destination: final_destination
|
||||
.path()
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
error: format!(
|
||||
"Copy succeeded but failed to delete source: {}",
|
||||
e
|
||||
));
|
||||
}
|
||||
),
|
||||
});
|
||||
ctx.add_non_critical_error(format!(
|
||||
"Failed to delete source after move {}: {}",
|
||||
resolved_source.display(),
|
||||
e
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
|
||||
use crate::domain::ContentKind;
|
||||
use crate::filetype::FileTypeRegistry;
|
||||
use crate::ops::indexing::metadata::{is_hidden_path, EntryMetadata};
|
||||
use crate::ops::indexing::metadata::EntryMetadata;
|
||||
use crate::ops::indexing::state::{EntryKind, IndexerStats};
|
||||
|
||||
use super::types::{
|
||||
@@ -265,7 +265,10 @@ impl Arena {
|
||||
let parent_ref = parent_id
|
||||
.map(MaybeEntryId::some)
|
||||
.unwrap_or(MaybeEntryId::NONE);
|
||||
let meta = PackedMetadata::new(NodeState::Accessible, FileType::Directory, 0);
|
||||
// A placeholder nobody has statted yet; the hidden answer has to come
|
||||
// from somewhere, and the walk corrects it when it reaches the entry.
|
||||
let meta = PackedMetadata::new(NodeState::Accessible, FileType::Directory, 0)
|
||||
.with_hidden(super::metadata::is_hidden_path(path));
|
||||
let node = FileNode::new(NameRef::new(name, parent_ref), meta);
|
||||
|
||||
let id = self.arena.insert(node)?;
|
||||
@@ -350,6 +353,7 @@ impl Arena {
|
||||
FileType::from(metadata.kind),
|
||||
metadata.size,
|
||||
)
|
||||
.with_hidden(metadata.is_hidden)
|
||||
.with_times(metadata.modified, metadata.created);
|
||||
node.subtree_bytes = current.bytes;
|
||||
node.file_count = current.files;
|
||||
@@ -401,6 +405,7 @@ impl Arena {
|
||||
let file_type = FileType::from(metadata.kind);
|
||||
|
||||
let meta = PackedMetadata::new(NodeState::Accessible, file_type, metadata.size)
|
||||
.with_hidden(metadata.is_hidden)
|
||||
.with_times(metadata.modified, metadata.created);
|
||||
|
||||
let parent_ref = parent_id
|
||||
@@ -501,7 +506,7 @@ impl Arena {
|
||||
uid: None,
|
||||
gid: None,
|
||||
link_target: None,
|
||||
is_hidden: is_hidden_path(path),
|
||||
is_hidden: node.meta.is_hidden(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -522,7 +527,7 @@ impl Arena {
|
||||
uid: None,
|
||||
gid: None,
|
||||
link_target: None,
|
||||
is_hidden: is_hidden_path(path),
|
||||
is_hidden: node.meta.is_hidden(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1065,6 +1070,17 @@ impl Arena {
|
||||
/// Runs on snapshot restore (flags are derived data and never persist),
|
||||
/// and is safe to re-run when classification heuristics change.
|
||||
pub fn recompute_collections(&mut self) {
|
||||
// Snapshots written before the arena kept the hidden bit have it
|
||||
// clear. Where the name alone decides, it is recomputed here; on
|
||||
// Windows the bit is what the walk read from the attribute, and no
|
||||
// snapshot there predates it.
|
||||
#[cfg(not(windows))]
|
||||
for (path, &id) in &self.path_index {
|
||||
if let Some(node) = self.arena.get_mut(id) {
|
||||
node.meta = node.meta.with_hidden(super::metadata::is_hidden_path(path));
|
||||
}
|
||||
}
|
||||
|
||||
self.collection_flags.clear();
|
||||
// Flags are derived from name and kind, neither of which says anything
|
||||
// about a summarised directory, so its marker is restored from the
|
||||
@@ -1278,6 +1294,7 @@ impl Arena {
|
||||
node.name_ref = NameRef::new(name, parent_ref);
|
||||
node.meta =
|
||||
PackedMetadata::new(NodeState::Accessible, FileType::from(to.kind), to.size)
|
||||
.with_hidden(to.is_hidden)
|
||||
.with_times(to.modified, to.created);
|
||||
node.subtree_bytes = rollup.bytes;
|
||||
node.file_count = rollup.files;
|
||||
@@ -1382,7 +1399,7 @@ impl Arena {
|
||||
uid: None,
|
||||
gid: None,
|
||||
link_target: None,
|
||||
is_hidden: is_hidden_path(path),
|
||||
is_hidden: node.meta.is_hidden(),
|
||||
};
|
||||
result.insert(path.clone(), metadata);
|
||||
}
|
||||
|
||||
@@ -166,13 +166,20 @@ impl From<FileType> for super::state::EntryKind {
|
||||
/// Layout:
|
||||
/// - Bits 62-63: state (2 bits)
|
||||
/// - Bits 60-61: type (2 bits)
|
||||
/// - Bits 0-59: size (60 bits, max ~1 exabyte)
|
||||
/// - Bit 59: hidden
|
||||
/// - Bits 0-58: size (59 bits, max ~576 petabytes)
|
||||
/// - mtime: seconds since epoch (32 bits)
|
||||
/// - ctime: seconds since epoch (32 bits)
|
||||
///
|
||||
/// Hidden lives here because it is read from the entry's own stat on Windows
|
||||
/// (the hidden attribute) and only from its name elsewhere, so the arena has
|
||||
/// to keep what the walk saw rather than ask the filesystem again on every
|
||||
/// read. Snapshots written before the bit existed carry sizes under 2^59, so
|
||||
/// the bit reads as clear and the restore recomputes it where a name decides.
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct PackedMetadata {
|
||||
/// Bits 62-63: state, 60-61: type, 0-59: size
|
||||
/// Bits 62-63: state, 60-61: type, 59: hidden, 0-58: size
|
||||
state_type_size: u64,
|
||||
/// Modified time (seconds since epoch, 0 = None)
|
||||
mtime: u32,
|
||||
@@ -181,13 +188,13 @@ pub struct PackedMetadata {
|
||||
}
|
||||
|
||||
impl PackedMetadata {
|
||||
const SIZE_MASK: u64 = (1u64 << 60) - 1;
|
||||
const SIZE_MASK: u64 = (1u64 << 59) - 1;
|
||||
const HIDDEN_BIT: u64 = 1u64 << 59;
|
||||
const TYPE_SHIFT: u32 = 60;
|
||||
const STATE_SHIFT: u32 = 62;
|
||||
|
||||
/// Create new packed metadata
|
||||
pub fn new(state: NodeState, file_type: FileType, size: u64) -> Self {
|
||||
// Clamp size to 60 bits (max ~1 exabyte)
|
||||
let size = size.min(Self::SIZE_MASK);
|
||||
let packed =
|
||||
size | ((file_type as u64) << Self::TYPE_SHIFT) | ((state as u64) << Self::STATE_SHIFT);
|
||||
@@ -214,6 +221,21 @@ impl PackedMetadata {
|
||||
NodeState::from_u8(((self.state_type_size >> Self::STATE_SHIFT) & 0b11) as u8)
|
||||
}
|
||||
|
||||
/// Record whether the walk saw the entry as hidden.
|
||||
pub fn with_hidden(mut self, hidden: bool) -> Self {
|
||||
if hidden {
|
||||
self.state_type_size |= Self::HIDDEN_BIT;
|
||||
} else {
|
||||
self.state_type_size &= !Self::HIDDEN_BIT;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether the walk saw the entry as hidden.
|
||||
pub fn is_hidden(&self) -> bool {
|
||||
self.state_type_size & Self::HIDDEN_BIT != 0
|
||||
}
|
||||
|
||||
/// Set timestamps
|
||||
pub fn with_times(mut self, mtime: Option<SystemTime>, ctime: Option<SystemTime>) -> Self {
|
||||
self.mtime = mtime
|
||||
@@ -539,9 +561,21 @@ mod tests {
|
||||
// Test that large sizes are clamped
|
||||
let meta = PackedMetadata::new(NodeState::Accessible, FileType::File, u64::MAX);
|
||||
|
||||
// Size should be clamped to 60-bit max
|
||||
assert_eq!(meta.size(), (1u64 << 60) - 1);
|
||||
// Size should be clamped to 59-bit max
|
||||
assert_eq!(meta.size(), (1u64 << 59) - 1);
|
||||
assert_eq!(meta.file_type(), FileType::File);
|
||||
assert!(!meta.is_hidden());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_packed_metadata_hidden_bit_leaves_the_rest_alone() {
|
||||
let meta = PackedMetadata::new(NodeState::Accessible, FileType::Directory, 12_345)
|
||||
.with_hidden(true);
|
||||
assert!(meta.is_hidden());
|
||||
assert_eq!(meta.size(), 12_345);
|
||||
assert_eq!(meta.file_type(), FileType::Directory);
|
||||
assert_eq!(meta.state(), NodeState::Accessible);
|
||||
assert!(!meta.with_hidden(false).is_hidden());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -130,7 +130,7 @@ impl ArenaChange {
|
||||
}
|
||||
|
||||
fn is_hidden(&self) -> bool {
|
||||
is_hidden_path(&self.path)
|
||||
self.metadata.as_ref().is_some_and(|m| m.is_hidden)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -649,7 +649,12 @@ mod tests {
|
||||
uid: None,
|
||||
gid: None,
|
||||
link_target: None,
|
||||
is_hidden: is_hidden_path(path),
|
||||
// As a walk would have read it, so the test does not depend on a
|
||||
// file existing on disk with the platform's hidden marker.
|
||||
is_hidden: path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.starts_with('.')),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1147,11 +1147,14 @@ mod tests {
|
||||
fn listed(output: &PathCompareOutput) -> Vec<String> {
|
||||
let named = |file: &Option<File>| {
|
||||
file.as_ref().map(|file| match &file.sd_path {
|
||||
// Store paths are joined onto the root with the platform's
|
||||
// separator; the expectations are written with slashes.
|
||||
SdPath::Physical { path, .. } => path
|
||||
.strip_prefix("/vol")
|
||||
.expect("beneath the volume")
|
||||
.display()
|
||||
.to_string(),
|
||||
.to_string()
|
||||
.replace('\\', "/"),
|
||||
other => panic!("{other:?} is not a physical path"),
|
||||
})
|
||||
};
|
||||
|
||||
@@ -601,7 +601,7 @@ mod tests {
|
||||
page.files
|
||||
.iter()
|
||||
.map(|file| match &file.sd_path {
|
||||
SdPath::Physical { path, .. } => path.display().to_string(),
|
||||
SdPath::Physical { path, .. } => path.display().to_string().replace('\\', "/"),
|
||||
other => panic!("replica files have physical paths, got {other}"),
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -468,6 +468,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn an_absolute_root_is_accepted() {
|
||||
assert!(TrackSourceAction::from_input(input("/Volumes/Archive")).is_ok());
|
||||
let root = if cfg!(windows) {
|
||||
r"D:\Archive"
|
||||
} else {
|
||||
"/Volumes/Archive"
|
||||
};
|
||||
assert!(TrackSourceAction::from_input(input(root)).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -477,8 +477,12 @@ mod tests {
|
||||
/// An unmounted drive leaves its mount point behind as an empty directory on
|
||||
/// the parent filesystem, and that directory passes every existence check.
|
||||
/// What tells the two apart is the device: a mount point's device differs
|
||||
/// from its parent's, or it is the filesystem root. Platforms without device
|
||||
/// numbers answer by existence, which is the check this replaces.
|
||||
/// from its parent's, or it is the filesystem root. Windows answers the same
|
||||
/// question through `GetVolumePathNameW`, which names the mount point of the
|
||||
/// volume holding a path: a path that is its own volume root is a mount point
|
||||
/// (a drive letter or a folder a volume is mounted at), while the directory a
|
||||
/// drive left behind resolves to the parent volume's root. Platforms with
|
||||
/// neither answer by existence, which is the check this replaces.
|
||||
pub fn is_mount_point(path: &Path) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -494,12 +498,62 @@ pub fn is_mount_point(path: &Path) -> bool {
|
||||
Err(_) => true,
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::ffi::{OsStrExt, OsStringExt};
|
||||
use windows_sys::Win32::Storage::FileSystem::GetVolumePathNameW;
|
||||
|
||||
if !path.is_dir() {
|
||||
return false;
|
||||
}
|
||||
// The path as given, not canonicalized: GetVolumePathNameW judges the
|
||||
// string it is handed, and a subst or mapped drive letter resolves
|
||||
// through canonicalize to the target it stands for, which would make
|
||||
// the drive's own root read as not a mount point.
|
||||
let wide: Vec<u16> = path
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let mut root = vec![0u16; 1024];
|
||||
if unsafe { GetVolumePathNameW(wide.as_ptr(), root.as_mut_ptr(), root.len() as u32) } == 0 {
|
||||
return false;
|
||||
}
|
||||
let len = root.iter().position(|&c| c == 0).unwrap_or(root.len());
|
||||
let root = std::ffi::OsString::from_wide(&root[..len]);
|
||||
let strip = |s: &std::ffi::OsStr| {
|
||||
s.to_string_lossy()
|
||||
.trim_start_matches(r"\\?\")
|
||||
.trim_end_matches(['\\', '/'])
|
||||
.to_ascii_lowercase()
|
||||
};
|
||||
strip(&root) == strip(path.as_os_str())
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
path.exists()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows))]
|
||||
mod mount_point_tests {
|
||||
use super::is_mount_point;
|
||||
use std::path::Path;
|
||||
|
||||
/// The system drive is a mount point, a directory on it is not, and a
|
||||
/// missing path is not.
|
||||
#[test]
|
||||
fn a_drive_root_is_a_mount_point_and_a_folder_on_it_is_not() {
|
||||
let system = std::env::var("SystemDrive").unwrap_or_else(|_| "C:".into());
|
||||
assert!(is_mount_point(Path::new(&format!("{system}\\"))));
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let left_behind = dir.path().join("mount");
|
||||
std::fs::create_dir(&left_behind).unwrap();
|
||||
assert!(!is_mount_point(&left_behind));
|
||||
assert!(!is_mount_point(&dir.path().join("missing")));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod mount_point_tests {
|
||||
use super::is_mount_point;
|
||||
|
||||
@@ -9,6 +9,12 @@ use sd_core::infra::daemon::types::{DaemonRequest, DaemonResponse};
|
||||
|
||||
use crate::client::CoreClient;
|
||||
|
||||
/// The daemon executable's file name next to the CLI: `sd-daemon`, or
|
||||
/// `sd-daemon.exe` on Windows.
|
||||
pub fn daemon_binary_name() -> String {
|
||||
format!("sd-daemon{}", std::env::consts::EXE_SUFFIX)
|
||||
}
|
||||
|
||||
/// How to launch the daemon executable when it is not already running.
|
||||
pub struct DaemonLaunchConfig {
|
||||
/// Path to the daemon executable.
|
||||
|
||||
@@ -18,7 +18,9 @@ mod library;
|
||||
|
||||
pub use broker::{BrokerOptions, BrokerSubscription, SubscriptionBroker};
|
||||
pub use client::{CoreClient, EventStream, LogStream};
|
||||
pub use daemon::{ensure_daemon, is_daemon_running, DaemonLaunchConfig, EnsureDaemonOutcome};
|
||||
pub use daemon::{
|
||||
daemon_binary_name, ensure_daemon, is_daemon_running, DaemonLaunchConfig, EnsureDaemonOutcome,
|
||||
};
|
||||
pub use library::LibraryContext;
|
||||
pub use sd_core::infra::daemon::addr::daemon_socket_addr;
|
||||
pub use sd_core::infra::daemon::types::{
|
||||
|
||||
+13
-5
@@ -82,8 +82,8 @@ explicit version tag.
|
||||
The nightly channel is a rolling build of the CLI and daemon for people who
|
||||
want to track `main` without compiling. Every push to `main` runs
|
||||
`.github/workflows/nightly.yml`, which builds `sd` and `sd-daemon`
|
||||
in release mode for macOS ARM64 and Linux x86-64 and republishes them on the
|
||||
GitHub prerelease tagged `nightly`. There is one nightly release and the
|
||||
in release mode for macOS ARM64, Linux x86-64 and Windows x86-64 and
|
||||
republishes them on the GitHub prerelease tagged `nightly`. There is one nightly release and the
|
||||
workflow edits it in place: it rewrites the title and notes, replaces every
|
||||
asset with `gh release upload --clobber`, removes assets for platforms that no
|
||||
longer build, and only then moves the `nightly` tag to the built commit, so
|
||||
@@ -91,6 +91,9 @@ the tag never names a commit whose binaries are not yet published. The
|
||||
release is created only when it does not exist. Asset URLs are stable because they are
|
||||
built from the tag and the asset name, for example
|
||||
`https://github.com/spacedriveapp/spacedrive/releases/download/nightly/sd-macos-aarch64`.
|
||||
Windows assets carry the `.exe` suffix (`sd-windows-x86_64.exe`,
|
||||
`sd-daemon-windows-x86_64.exe`), which the updater strips when it matches an
|
||||
asset to a binary.
|
||||
|
||||
The release notes name the commit the build came from. Because GitHub freezes
|
||||
a release's `target_commitish` at creation, that field does not move with the
|
||||
@@ -106,7 +109,9 @@ Nightly builds are not a release:
|
||||
- They are not signed with a Developer ID and are not notarized. The macOS
|
||||
linker adds an ad-hoc signature, which is enough to run a binary the CLI
|
||||
downloaded itself. A copy downloaded in a browser carries the quarantine
|
||||
attribute and Gatekeeper refuses it until you remove that attribute.
|
||||
attribute and Gatekeeper refuses it until you remove that attribute. The
|
||||
Windows binaries have no Authenticode signature, so SmartScreen warns the
|
||||
first time you run one you downloaded in a browser.
|
||||
- They can break, lose data, or change schemas without migrations. Keep
|
||||
backups of any library you point a nightly build at.
|
||||
- They carry sd-core's default features only: no bundled FFmpeg, libheif, or
|
||||
@@ -150,8 +155,11 @@ both the package version and the installed commit, for example
|
||||
`sd update --yes` skips the confirmation prompt and does nothing when the
|
||||
installed commit already matches. `sd update --force` reinstalls the current
|
||||
nightly even when it matches. Both replace the running `sd` binary and the
|
||||
`sd-daemon` next to it through a temporary file and a rename, stop the daemon
|
||||
first, and start it again afterwards. When `sd daemon install` has launchd
|
||||
`sd-daemon` next to it through a `.bak` copy and a rename of the downloaded
|
||||
file into place, stop the daemon first, and start it again afterwards. Windows
|
||||
cannot replace or delete a running executable, so there the current binary is
|
||||
moved to `.bak` first and `sd.exe.bak` stays beside the binaries until the
|
||||
next update removes it. When `sd daemon install` has launchd
|
||||
managing the daemon, the restart goes through `launchctl kickstart` so launchd
|
||||
keeps owning the process. After an install the build's commit (or the release
|
||||
tag on the stable channel) is written to `sd-version.txt` beside the binaries.
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# Windows: distance from the beta gate
|
||||
|
||||
> Status: measured 2026-10-09 on GitHub-hosted `windows-latest` from
|
||||
> `03efcad` plus the fixes in #3148; the `Core Tests (windows unit)` job in
|
||||
> `core_tests.yml` is the standing measurement
|
||||
> Captured: 2026-10-09
|
||||
> Owns: what builds, what passes and what fails on Windows at `main`, the size
|
||||
> of each gap, what the FDA and R8 acceptance matrices would need to run
|
||||
> there, and the beta.1 recommendation
|
||||
> Register: `PROJECT_STATUS.md`
|
||||
> Companions: `docs/core/releases.mdx` (the cross-platform gate),
|
||||
> `docs/core/acceptance/entries-drop-and-file-operations.md` (FDA),
|
||||
> `docs/core/acceptance/source-runtime.md` (R8),
|
||||
> `docs/core/acceptance/volumes.md` (locked volumes)
|
||||
|
||||
## Outcome
|
||||
|
||||
Windows had not been built since the rewrite. The first build on a real
|
||||
Windows runner compiled `sd-core`, `sd-cli` and `sd-daemon` with no source
|
||||
change, and the `sd-core` lib suite ran 605 of 613 tests green at `main`.
|
||||
Three of the eight failures were Windows bugs in the product and five were
|
||||
tests written in Unix shapes; all eight are fixed in #3148 and the second run
|
||||
is green: 615 passed, 4 ignored (the two FDA trash rows and two Linux-only
|
||||
tests). The CLI's own tests, including the MCP test that
|
||||
spawns `sd-cli.exe` against an in-process core, also pass. Nightly now
|
||||
publishes `sd-windows-x86_64.exe` and `sd-daemon-windows-x86_64.exe`, and
|
||||
`sd update` installs them.
|
||||
|
||||
What has not run on Windows is everything above the unit layer: the
|
||||
integration suites, the FDA and R8 acceptance matrices, and the locked
|
||||
volumes suite. Those are the cross-platform gate's evidence, and the section
|
||||
below sizes what each needs.
|
||||
|
||||
## Measurements
|
||||
|
||||
Runner: `windows-latest` (Windows Server 2025, 4 vCPU, 16 GB). Toolchain
|
||||
installed by `setup-rust` from `rust-toolchain.toml` (1.97.1).
|
||||
|
||||
| Step | Cold | Notes |
|
||||
|---|---|---|
|
||||
| `cargo build -p sd-cli -p sd-core --bin sd-cli --bin sd-daemon` (debug, no debuginfo) | 32 min | 769 crates. No C dependency failed; `libsqlite3-sys` bundled, `openssl-sys` not compiled on this target (reqwest uses schannel) |
|
||||
| `cargo test -p sd-core --lib` build | 9 min | on top of the build above |
|
||||
| `cargo test -p sd-core --lib` run | 3 min | 613 tests |
|
||||
| `cargo test -p sd-cli` | 7 min | 16 lib tests plus the MCP stdio test, which spawns `sd-cli.exe` against an in-process core |
|
||||
| Whole job | 53 min cold | `continue-on-error`; cache saved on red runs too (`cache-on-failure`) so the next run warms |
|
||||
|
||||
A warm run has not been measured yet: the first run failed before
|
||||
`rust-cache` saved, and the cache now saves on failure. Expect the
|
||||
dependency half of the 32 minutes to drop; the workspace crates rebuild every
|
||||
run regardless (`rust-cache` keys out workspace members), so the floor is the
|
||||
`sd-core` compile on a 4 vCPU Windows host, likely 15 to 20 minutes. The
|
||||
brief's 40 minute budget needs that warm measurement. Blacksmith has no
|
||||
Windows runners; a self-hosted Windows machine would be the other lever.
|
||||
|
||||
## What passed at `main` (no change needed)
|
||||
|
||||
- Compiling. `cargo check -p sd-core -p sd-cli --lib --bins --tests` for
|
||||
`x86_64-pc-windows-gnu` from Linux with mingw is also clean, and is a
|
||||
nine minute way to catch cfg gaps without a Windows runner.
|
||||
- 605 `sd-core` lib tests, including the file operation fixtures (copy,
|
||||
merge, delete, trash the OS way, rename, archive), the indexing arena,
|
||||
stores, search, sync, the volume index and the daemon bootstrap.
|
||||
- Daemon and CLI talk over TCP loopback (`127.0.0.1:6969`), so nothing
|
||||
needed a Unix socket.
|
||||
|
||||
## What failed at `main` and the fixes (#3148)
|
||||
|
||||
| # | Test | Cause | Kind | Fix |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `ops::files::acceptance::preview_rows_match_execution_for_copy_move_merge_and_delete` | A move inside one volume chose the streaming copy, then the delete of the source was gated on `VolumeManager::same_volume`, which answered "same volume" and skipped it, so the file ended in both places and the job reported success | product bug | The job checks whether the source still exists after the strategy ran and removes it then |
|
||||
| 2 | `ops::indexing::writer::tests::a_walk_lands_as_one_event` | Hidden is a file attribute on Windows, read with `GetFileAttributesW`. The arena rebuilt `is_hidden` from the path on every read instead of keeping what the walk saw, so anything not on disk at that moment (a test fixture, a snapshot, a detached drive) read as not hidden, and every arena read was a syscall | product bug | `PackedMetadata` carries a hidden bit (size field drops to 59 bits); the arena records what the walk saw; the writer judges a change by the metadata it carries; snapshot restore recomputes the bit from names where names decide |
|
||||
| 3 | `ops::search::arena_search::tests::the_store_backend_matches_the_arena_for_the_same_capture` | Same as 2: the arena returned `.secret.mov` from a hidden-excluded search | product bug | Same fix |
|
||||
| 4 | `ops::indexing::volume_index::tests::a_mapped_drive_that_is_away_keeps_its_partition` | `volume::utils::is_mount_point` was `path.exists()` on Windows, so the empty directory an unplugged mapped drive leaves behind passed as mounted and a detached volume could be dispatched at it | product bug (the `get_inode`-style stub the brief named) | `GetVolumePathNameW` names the mount point of the volume holding a path; a directory that is its own volume root is a mount point |
|
||||
| 5 | `ops::search::media::tests::a_replica_pages_its_media_in_path_order` | Hidden (as 2) plus a displayed path compared against a slash-written expectation | test | Normalise separators in the test helper |
|
||||
| 6, 7 | `ops::paths::compare::tests::by_path_sorts_each_file_into_its_set`, `by_content_finds_bytes_wherever_they_sit` | Displayed paths compared against slash-written expectations | test | Same |
|
||||
| 8 | `ops::sources::track::action::tests::an_absolute_root_is_accepted` | `/Volumes/Archive` is not absolute on Windows | test | A platform-absolute root |
|
||||
|
||||
Also fixed in the PR without a failing test: the CLI built the daemon's path
|
||||
as `sd-daemon` everywhere (now `sd_client::daemon_binary_name()`, which
|
||||
appends `.exe` on Windows), and `sd update` renamed the staged file over the
|
||||
running binary, which Windows refuses for a loaded executable (now the
|
||||
current binary moves to `.bak` first; `sd.exe.bak` stays until the next
|
||||
update removes it, since a running executable cannot be deleted).
|
||||
|
||||
## Gap list: what has not run on Windows
|
||||
|
||||
Sizes are for one person with a Windows machine or the CI job as the loop
|
||||
(each CI iteration is 50 minutes cold, so a machine is the faster loop).
|
||||
|
||||
| Gap | What it is | Size | Blocks |
|
||||
|---|---|---|---|
|
||||
| Store relative paths joined with `/` | Store rows keep `rel_path` with `/`. Twenty call sites do `root.join(&entry.relative_path)`, which on Windows yields `C:\root\a/b.txt`. `Path` compares by components so lookups work, but every displayed or serialised path carries mixed separators, and any string comparison on the result is wrong | half a day: one `join_rel` helper split on `/`, the twenty sites, a test | cosmetic in the UI, wrong for string-keyed sets |
|
||||
| Trash restore (FDA F3, F5, F10) | `trash` crate's Windows item id is a shell parsing name, not a path, so the trash view cannot stat an item and restore goes through the crate blind. Two acceptance tests are `#[ignore]` on Windows for this | one day: record the recycle bin item through `IShellItem`/`SHGetKnownFolderPath` or the crate's `os_limited` API, then un-ignore | the FDA matrix |
|
||||
| Integration suites (`xtask test-core --integration`) | 40 targets, never run on Windows. They assume `scripts/setup.sh` system packages (bun for the TypeScript bridge tests), a Desktop directory, user volumes, and two daemons pairing over loopback. On Linux 20 of them failed on a bare VM until SPAC-6 fixed environment assumptions | one to two days: a second Windows job on the same cache, triage per suite the way SPAC-6 did on Linux | the gate's "mount, watcher, permission" rows |
|
||||
| FDA matrix on Windows | `entries_drop_acceptance_test.rs` and `core/src/ops/files/acceptance.rs` (the latter now runs green in the lib suite minus the two trash rows). The integration half needs the job above | included in the integration triage | cross-platform gate |
|
||||
| R8 matrix on Windows | `source_runtime_acceptance_test.rs` and the two-process `source_replication_test.rs`; `crates/store/tests`. Rows 9 (APFS) and 26 are not automatable anywhere but macOS; the rest should run. Watcher rows (11) go through `crates/fs-watcher/src/platform/windows.rs` (ReadDirectoryChangesW), which has never been exercised in CI | one day after the integration job exists | cross-platform gate |
|
||||
| `sd-fs-watcher --lib` on Windows | The unit job on Linux runs it; the Windows job does not yet. Add it once the first run shows what the Windows handler does with the shared tests | an hour to add, unknown to fix | watcher row |
|
||||
| Locked volumes (`volumes.md` rows 5, 6) | Linux uses loop mounts and ZFS; Windows has `diskpart` VHDs (`test_volumes.rs` has a `create_vhd` helper that nothing calls). Mapped drives and BitLocker are the real cases | two days | gate's removable drive row |
|
||||
| Update timer | `sd update install-timer` is launchd and systemd only; Windows returns a clear error. Task Scheduler (`schtasks`) is the equivalent | half a day | nightly users on Windows, not the gate |
|
||||
| Signing | Nightly Windows binaries are unsigned; SmartScreen warns on a browser download. Release needs an Authenticode certificate, which nobody has started | Jamie's decision; days of paperwork | release, not nightly |
|
||||
| Desktop (Tauri) on Windows | Not built in this cycle at all; `release.yml` has a Windows desktop row that has never run on this tree | unknown | desktop beta on Windows |
|
||||
| Warm CI time | Unmeasured, see above | one run | keeping the job under 40 minutes |
|
||||
|
||||
Not gaps: filesystem metadata (`EntryMetadata` reads `created`, inode as the
|
||||
NTFS file id, no `uid`/`gid`), hidden files, mount points, device identity
|
||||
(`domain/device.rs` has Windows branches throughout), the volume manager's
|
||||
`\\?\` prefix handling, path spelling through `locate_path`. These are what
|
||||
the 605 green tests cover.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Scope Windows out of beta.1 for the desktop and the acceptance claims, and
|
||||
keep the CLI and daemon on the nightly channel for Windows as a preview.
|
||||
|
||||
The reasoning: the compile and unit layer is in better shape than the
|
||||
roadmap feared (eight failures, three real, all fixed in a day), so this is
|
||||
weeks of unbuilt work rather than months, but the gate asks for the FDA and
|
||||
R8 matrices and the mount, watcher, permission and removable drive rows to
|
||||
pass on Windows, and none of that has run once. The integration triage alone
|
||||
took the Linux side two threads (SPAC-2, SPAC-6) with a fast machine in the
|
||||
loop; on Windows the loop is a 50 minute hosted runner unless someone
|
||||
provides a Windows machine. With the beta on November 1 and Windows first on
|
||||
the cut list in `roadmap-to-beta.md`, the honest claim for beta.1 is what the
|
||||
CI job proves: it builds, the unit layer passes, the binaries install and
|
||||
update themselves.
|
||||
|
||||
Proposed release notes text:
|
||||
|
||||
> Spacedrive 2.0 beta.1 supports macOS (Apple silicon) and Linux (x86-64).
|
||||
> Windows is not part of this beta. The daemon and CLI build and pass their
|
||||
> unit tests on Windows, and nightly builds for Windows x86-64 are published
|
||||
> for people who want to try them, but the file operation, source runtime and
|
||||
> drive acceptance suites have not been run there, so we do not yet make the
|
||||
> data safety claims this release makes for macOS and Linux. Windows support
|
||||
> is planned for a later beta.
|
||||
|
||||
If Jamie wants Windows in beta.1 instead: the path is a Windows machine with
|
||||
rustup (or a self-hosted runner), the integration job, then the trash and
|
||||
relative-path fixes, then the matrices; three weeks of one thread's time
|
||||
with the loop on a real machine, longer on the hosted runner. That does not
|
||||
fit before November 1 alongside the other gates.
|
||||
|
||||
## Keeping the job honest
|
||||
|
||||
- `Core Tests (windows unit, continue-on-error until green twice)` runs on
|
||||
every push to `main` and every PR. Drop `continue-on-error` and shorten the
|
||||
name once it has been green twice on `main`.
|
||||
- A failing Windows test is a bug or a Unix-shaped test. Fix the test's shape
|
||||
only when what it asserts is platform-neutral; `cfg(windows)`-ignore only
|
||||
with the reason in the attribute, the way the FDA trash rows are.
|
||||
- Before a Windows runner, `cargo check --target x86_64-pc-windows-gnu` with
|
||||
mingw on Linux catches cfg gaps in nine minutes.
|
||||
Reference in new issue
Block a user