diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7e15e84..3b99874 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -364,14 +364,17 @@ jobs: if (Test-Path dist) { Remove-Item -Recurse -Force dist } New-Item -ItemType Directory -Force "dist/$name" | Out-Null Copy-Item "target/${{ matrix.target }}/release/${{ matrix.binary }}", README.md "dist/$name/" - # Bundle icebug DLLs + algo extension (GDS_* via Cypher) next to the exe. - New-Item -ItemType Directory -Force "dist/$name/icebug/lib" | Out-Null - New-Item -ItemType Directory -Force "dist/$name/liblbug" | Out-Null - Get-ChildItem "icebug/lib" -Filter *.dll -Recurse | ForEach-Object { Copy-Item $_.FullName "dist/$name/icebug/lib/" } - Get-ChildItem "liblbug" -Filter *.dll | ForEach-Object { Copy-Item $_.FullName "dist/$name/liblbug/" } - # The INSTALL'd algo extension loads arrow dynamically: ship the - # vcpkg arrow DLLs next to the binary (on PATH at runtime). - Get-ChildItem "vcpkg_installed/x64-windows/bin" -Filter arrow*.dll | ForEach-Object { Copy-Item $_.FullName "dist/$name/icebug/lib/" } + # Windows has no RPATH: the loader only looks next to the exe, in + # System32, and on PATH — never in our icebug/lib + liblbug + # subdirs. stage_windows_bundle.sh copies every runtime DLL flat + # next to the exe (lbug_shared, networkit_state, arrow AND its + # bz2/brotli/lz4/... family from vcpkg, plus OpenSSL when + # dynamically linked) and fails the build if a known dep is + # missing. See https://github.com/LadybugDB/bugscope/issues/5. + $vcpkgBin = Join-Path $env:GITHUB_WORKSPACE 'vcpkg_installed\x64-windows\bin' + $osslBin = Join-Path $env:OPENSSL_DIR 'bin' + bash scripts/stage_windows_bundle.sh "dist/$name" "$vcpkgBin" "$osslBin" "$env:OPENSSL_DIR" + Get-ChildItem "dist/$name" -Filter *.dll | Select-Object Name, Length Compress-Archive -Path "dist/$name" -DestinationPath "dist/$name.zip" $hash = (Get-FileHash "dist/$name.zip" -Algorithm SHA256).Hash.ToLower() [IO.File]::WriteAllText("dist/$name.zip.sha256", "$hash $name.zip`n") diff --git a/README.md b/README.md index 9f91e5d..c84d156 100644 --- a/README.md +++ b/README.md @@ -125,3 +125,4 @@ Summary-space PageRank sidecars, LLM cluster naming, Voronoi overlay, the lever - `Load failed: ...` in the status bar - the picked file isn't a readable LadybugDB database, or it's locked by another process. - Search returns nothing - search scans node `name`/`title`/`label`/`id` plus all properties (first 50k nodes, 50 results); check the query box text and the selected database. - Opening a file fails with `Failed to load library: .../.lbdb/extension//.../libalgo.lbug_extension` - that DB ran `LOAD algo` before, which ladybug WAL-logs and replays on every open, so one stale cached build bricks the file. Library paths are not the cause (the binary resolves `@rpath` deps from its bundled `Frameworks` plus Homebrew/system locations, no `DYLD_LIBRARY_PATH` needed): it is symbol skew between the cached build and `liblbug` (check with plain `dlopen` — `symbol not found in flat namespace` means skew, `Library not loaded` means paths). Fix: back up, then replace the named cached file with a symbol-matching build (a plain `LOAD EXTENSION` of that file must succeed); `INSTALL algo` may re-serve the stale build, so avoid re-running it until upstream publishes matching bits. `BUGSCOPE_ALGO_EXTENSION` only affects post-open `LOAD`, not WAL replay. +- `bugscope.exe` won't start (`*.dll was not found`, Windows) - fixed by staging every runtime DLL flat next to the exe: the Windows loader (unlike ELF `RPATH` / Mach-O `@rpath`) never looks in the `icebug/` + `liblbug/` subdirs. Current zips ship `lbug_shared`, `networkit_state`, `arrow` *and* its `bz2`/`brotli`/`lz4` family plus OpenSSL beside the exe (`scripts/stage_windows_bundle.sh`, asserted in CI), and the exe additionally prepends its bundled subdirs to `PATH` for runtime `LOAD EXTENSION` deps. On an older zip, copy *all* `*.dll` from `icebug/lib` (including `icebug/lib/networkit`), `liblbug`, and the Arrow runtime next to the exe — copying just the three named DLLs only reaches the next missing-dep error. diff --git a/scripts/stage_windows_bundle.sh b/scripts/stage_windows_bundle.sh new file mode 100755 index 0000000..34e9e30 --- /dev/null +++ b/scripts/stage_windows_bundle.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# Stage Windows runtime DLLs for the release zip. +# +# bash scripts/stage_windows_bundle.sh [extra-dll-dir ...] +# +# Copies every runtime DLL flat next to `bugscope.exe` AND preserves the +# historical `icebug/lib` + `liblbug` subdir layout. The flat copies are the +# fix for https://github.com/LadybugDB/bugscope/issues/5: the Windows loader +# resolves load-time DLLs *before* `main` runs and searches only the exe +# directory, System32, the Windows directory, and `PATH` — there is no +# RPATH/`@rpath` equivalent, so DLLs sitting in subdirs (`lbug_shared.dll`, +# `networkit_state.dll`, `arrow.dll`, …) were invisible and the exe refused +# to start. Hand-copying those then surfaced round two (`bz2.dll`, +# `brotlidec.dll`, `brotlienc.dll`, `lz4.dll`, …): Arrow's transitive +# compression deps, which the old packaging never shipped because it globbed +# only `arrow*.dll`. This script therefore stages *all* DLLs from the +# caller-supplied dirs (vcpkg `bin`, OpenSSL `bin`, …), not just arrow's. +# +# Typical CI invocation (pwsh step): +# bash scripts/stage_windows_bundle.sh "dist/$name" "$vcpkgBin" "$osslBin" +# +# Fails fast when a known load-time dep is still missing flat next to the +# exe, so a packaging regression breaks the build instead of the user. +set -euo pipefail + +DIST_DIR="${1:?usage: stage_windows_bundle.sh [extra-dll-dir ...]}" +shift + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# The pwsh CI step may hand us `D:\...` paths under git-bash: normalize when +# cygpath exists, otherwise pass through (macOS/Linux shells lack it). +norm() { + if command -v cygpath >/dev/null 2>&1; then + cygpath -u "$1" 2>/dev/null || printf '%s' "$1" + else + printf '%s' "$1" + fi +} +DIST_DIR="$(norm "$DIST_DIR")" + +EXE="$DIST_DIR/bugscope.exe" +[ -f "$EXE" ] || { echo "stage_windows_bundle.sh: missing $EXE" >&2; exit 1; } + +staged=0 +stage_flat() { + # stage_flat : copy basename next to the exe, first wins on + # (case-insensitive) name clashes so vcpkg/icebug/liblbug duplicates + # cannot silently overwrite each other mid-loop. + local src="$1" base dest + base="$(basename "$src")" + dest="$DIST_DIR/$base" + if [ -f "$dest" ]; then + return 0 + fi + cp -f "$src" "$dest" + echo "staged $base (from $src)" + staged=$((staged + 1)) +} + +# 1) icebug DLLs, recursive: networkit_state.dll lives in lib/networkit/ +# (the win prebuilt statically links networkit.lib and splits only +# GlobalState into that DLL), plus the parallel-leiden extension DLLs. +if [ -d "$PROJECT_DIR/icebug/lib" ]; then + while IFS= read -r dll; do + [ -n "$dll" ] && stage_flat "$dll" + done < <(find "$PROJECT_DIR/icebug/lib" -iname '*.dll' -type f | sort) || true +fi + +# 2) shared liblbug (lbug_shared.dll) — top level only. +if [ -d "$PROJECT_DIR/liblbug" ]; then + while IFS= read -r dll; do + [ -n "$dll" ] && stage_flat "$dll" + done < <(find "$PROJECT_DIR/liblbug" -maxdepth 1 -iname '*.dll' -type f | sort) || true +fi + +# 3) caller-supplied dirs (vcpkg bin, OpenSSL bin, ...): every DLL, top +# level. This is the issue-#5 round-two fix: arrow's bz2 / brotli / lz4 / +# zstd / snappy / ssl / crypto family lives here. +for raw in "$@"; do + [ -n "$raw" ] || continue + dir="$(norm "$raw")" + if [ ! -d "$dir" ]; then + echo "warning: extra dll dir not found, skipping: $raw" >&2 + continue + fi + while IFS= read -r dll; do + [ -n "$dll" ] && stage_flat "$dll" + done < <(find "$dir" -maxdepth 1 -iname '*.dll' -type f | sort) || true +done + +# 4) keep the historical subdir layout too (with the real `networkit/` +# nesting, which the old flattened copy dropped): harmless, and it keeps +# dev-tree-shaped path references working. +mkdir -p "$DIST_DIR/icebug" "$DIST_DIR/liblbug" +if [ -d "$PROJECT_DIR/icebug/lib" ]; then + cp -rf "$PROJECT_DIR/icebug/lib" "$DIST_DIR/icebug/" +fi +if [ -d "$PROJECT_DIR/liblbug" ]; then + find "$PROJECT_DIR/liblbug" -maxdepth 1 -iname '*.dll' -type f \ + -exec cp -f {} "$DIST_DIR/liblbug/" \; +fi + +# 5) fail fast when a known load-time dep is still missing flat: a +# regression must break the build, not the user's launch. +missing=0 +for want in lbug_shared.dll networkit_state.dll; do + if [ ! -f "$DIST_DIR/$want" ]; then + echo "ERROR: required DLL not staged flat next to bugscope.exe: $want" >&2 + missing=1 + fi +done +if ! ls "$DIST_DIR"/arrow*.dll >/dev/null 2>&1; then + echo "ERROR: no arrow*.dll staged flat in $DIST_DIR" >&2 + missing=1 +fi +# Transitive compression family (issue #5 round two): warn, don't fail — the +# exact vcpkg-mapped names drift (bz2/bzip2/brotli/lz4/zstd/snappy/...). +if ! ls "$DIST_DIR" | grep -qiE 'bz2|bzip|brotli|lz4|zstd|snappy'; then + echo "warning: no compression-family DLL (bz2/brotli/lz4/...) staged flat; arrow may fail at runtime" >&2 +fi +if [ "$missing" -ne 0 ]; then + exit 1 +fi + +echo "staged $staged DLL(s) flat next to bugscope.exe" diff --git a/src/lib.rs b/src/lib.rs index 5491571..25c0ab4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,3 +15,4 @@ pub mod clusters; pub mod model; pub mod theme; pub mod ui; +pub mod windows_dll; diff --git a/src/main.rs b/src/main.rs index 2e2803e..05bc94b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,6 +35,13 @@ fn with_view(cx: &mut App, f: impl FnOnce(&mut RootView, &mut Context) } fn main() { + // Windows: broaden the DLL search path before anything backend-adjacent + // loads (LOAD EXTENSION + transitive arrow/bz2/brotli/lz4 deps). This + // only helps *runtime* loads — load-time DLLs need the flat zip layout + // from scripts/stage_windows_bundle.sh, resolved before main runs. + #[cfg(windows)] + bugscope::windows_dll::init(); + let args: Vec = std::env::args().collect(); let opts = match parse_cli(&args) { Ok(None) => return, // --help / --version already printed diff --git a/src/windows_dll.rs b/src/windows_dll.rs new file mode 100644 index 0000000..772b96d --- /dev/null +++ b/src/windows_dll.rs @@ -0,0 +1,160 @@ +//! Windows DLL search-path bootstrap. +//! +//! Issue [LadybugDB/bugscope#5](https://github.com/LadybugDB/bugscope/issues/5): +//! `bugscope.exe` failed to start, first with `arrow.dll` / `lbug_shared.dll` +//! / `networkit_state.dll` not found, then — after hand-copying those — with +//! `bz2.dll` / `brotlidec.dll` / `brotlienc.dll` / `lz4.dll` not found. +//! +//! Root cause (packaging): the Windows loader resolves load-time DLLs +//! *before* `main` runs and searches only the exe directory, System32, the +//! Windows directory, and `PATH` — there is no `RPATH`/`@rpath` equivalent, +//! so DLLs sitting in our `icebug/lib` + `liblbug` subdirs were invisible. +//! The release zip must therefore stage every runtime DLL flat next to the +//! exe (see `scripts/stage_windows_bundle.sh`, invoked by the Windows +//! `Package` CI step), including *all* of vcpkg's Arrow DLLs rather than +//! just `arrow*.dll`. +//! +//! This module is defense-in-depth for everything loaded *after* startup — +//! `LOAD EXTENSION` (`libalgo`, `libfts`, …) plus their transitive deps. It +//! prepends the exe dir and the bundled subdirs to `PATH` (plain +//! `LoadLibrary` consults `PATH` at call time), so a non-flat layout (dev +//! tree, old zip) still resolves runtime loads. It cannot help load-time +//! DLLs — nothing running inside `main` can. + +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; + +/// Bundled subdirs (relative to the exe dir) that may hold runtime DLLs. +const BUNDLED_DLL_SUBDIRS: &[&str] = &["icebug/lib", "icebug/lib/networkit", "liblbug"]; + +/// Candidate DLL search dirs for an exe living in `exe_dir`: the exe dir +/// itself first (flat release-zip layout), then the bundled subdirs (dev +/// tree / old layout). Pure so it is unit-testable on any platform. +pub fn dll_search_dirs(exe_dir: &Path) -> Vec { + let mut out = Vec::with_capacity(BUNDLED_DLL_SUBDIRS.len() + 1); + out.push(exe_dir.to_path_buf()); + for sub in BUNDLED_DLL_SUBDIRS { + let dir = exe_dir.join(sub); + if !out.contains(&dir) { + out.push(dir); + } + } + out +} + +/// Join `dirs` in front of `current_path` (`None` = no `PATH` set). +/// Returns `None` when no dir should be prepended. +fn join_with_path(dirs: &[PathBuf], current_path: Option<&OsStr>) -> Option { + if dirs.is_empty() { + return None; + } + let mut all = Vec::with_capacity(dirs.len() + 1); + all.extend(dirs.iter().cloned()); + if let Some(current) = current_path { + if !current.is_empty() { + all.extend(std::env::split_paths(current)); + } + } + std::env::join_paths(all).ok() +} + +/// Compute the `PATH` value for `exe_dir` (existing candidate dirs first, +/// then `current_path`), or `None` when nothing should be prepended. Pure; +/// the thin [`prepend_dll_dirs_to_path`] wrapper applies it to the process. +fn new_path_for_exe_dir(exe_dir: &Path, current_path: Option<&OsStr>) -> Option { + let existing: Vec = dll_search_dirs(exe_dir) + .into_iter() + .filter(|p| p.is_dir()) + .collect(); + join_with_path(&existing, current_path) +} + +/// Prepend every *existing* candidate dir to `PATH` so later `LoadLibrary` +/// calls (e.g. `LOAD EXTENSION` and its transitive deps) resolve them. +pub fn prepend_dll_dirs_to_path(exe_dir: &Path) { + if let Some(joined) = new_path_for_exe_dir(exe_dir, std::env::var_os("PATH").as_deref()) { + std::env::set_var("PATH", joined); + } +} + +/// Call first in `main` on Windows. Best-effort: never panics, never fails +/// startup when dirs are absent. +pub fn init() { + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + prepend_dll_dirs_to_path(dir); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn search_dirs_are_exe_dir_then_bundled_subdirs() { + let exe_dir = Path::new("C:/dist/bugscope-0.21.2-windows"); + assert_eq!( + dll_search_dirs(exe_dir), + vec![ + exe_dir.to_path_buf(), + exe_dir.join("icebug/lib"), + exe_dir.join("icebug/lib/networkit"), + exe_dir.join("liblbug"), + ] + ); + } + + #[test] + fn prepend_keeps_old_path_tail() { + let dirs = vec![PathBuf::from("/opt/app"), PathBuf::from("/opt/app/liblbug")]; + // Build the fake old PATH with join_paths so the test is + // separator-agnostic (`:` on Unix, `;` on Windows). + let old = std::env::join_paths(["/usr/bin", "/bin"]).unwrap(); + let joined = join_with_path(&dirs, Some(&old)).unwrap(); + let mut parts = std::env::split_paths(&joined); + assert_eq!(parts.next().unwrap(), PathBuf::from("/opt/app")); + assert_eq!(parts.next().unwrap(), PathBuf::from("/opt/app/liblbug")); + let tail: Vec = parts.collect(); + assert_eq!(tail, vec![PathBuf::from("/usr/bin"), PathBuf::from("/bin")]); + } + + #[test] + fn prepend_without_old_path_yields_dirs_only() { + let dirs = vec![PathBuf::from("/opt/app")]; + let joined = join_with_path(&dirs, None).unwrap(); + assert_eq!(joined, OsString::from("/opt/app")); + } + + #[test] + fn prepend_with_no_dirs_leaves_path_alone() { + let old = std::env::join_paths(["/usr/bin"]).unwrap(); + assert_eq!(join_with_path(&[], Some(&old)), None); + } + + #[test] + fn new_path_skips_missing_dirs() { + let base = tempfile::tempdir().unwrap(); + let exe_dir = base.path(); + std::fs::create_dir_all(exe_dir.join("icebug/lib")).unwrap(); + // `icebug/lib/networkit` + `liblbug` intentionally absent. + let old = std::env::join_paths(["/usr/bin"]).unwrap(); + let joined = new_path_for_exe_dir(exe_dir, Some(&old)).unwrap(); + let parts: Vec = std::env::split_paths(&joined).collect(); + assert!(parts.contains(&exe_dir.to_path_buf())); + assert!(parts.contains(&exe_dir.join("icebug/lib"))); + assert!(!parts.contains(&exe_dir.join("liblbug"))); + assert!(parts.contains(&PathBuf::from("/usr/bin"))); + } + + #[test] + fn new_path_is_none_when_nothing_exists() { + let base = tempfile::tempdir().unwrap(); + // No subdirs created, and the exe dir itself always exists — so + // point at a guaranteed-absent dir instead. + let exe_dir = base.path().join("no-such-dir"); + let old = std::env::join_paths(["/usr/bin"]).unwrap(); + // The exe dir itself is missing too, so every candidate is absent. + assert_eq!(new_path_for_exe_dir(&exe_dir, Some(&old)), None); + } +}