Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<ver>/.../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.
127 changes: 127 additions & 0 deletions scripts/stage_windows_bundle.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# Stage Windows runtime DLLs for the release zip.
#
# bash scripts/stage_windows_bundle.sh <dist-dir> [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 <dist-dir> [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 <dll-path>: 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"
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ pub mod clusters;
pub mod model;
pub mod theme;
pub mod ui;
pub mod windows_dll;
7 changes: 7 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ fn with_view(cx: &mut App, f: impl FnOnce(&mut RootView, &mut Context<RootView>)
}

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<String> = std::env::args().collect();
let opts = match parse_cli(&args) {
Ok(None) => return, // --help / --version already printed
Expand Down
160 changes: 160 additions & 0 deletions src/windows_dll.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf> {
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<OsString> {
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<OsString> {
let existing: Vec<PathBuf> = 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<PathBuf> = 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<PathBuf> = 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);
}
}
Loading