From 6924da54a8a2991196603c33fb3144609102c88f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 22:45:25 +0000 Subject: [PATCH 01/12] feat(linux): drive LabVIEW for Linux Measured against LabVIEW 2026 Q3 in NI's Linux container image; each of these broke install, relink, hooks, refresh or venvs there: - targets: find installs via /etc/natinst and /usr/local/natinst, require the `labview` executable, read the minor version from readme/UNINSTALL - preferences: ~/natinst/.config/LabVIEW-/labview.conf instead of /LabVIEW.ini; absent until first launch, and VI Server is off by default there - PTH0: decode Linux paths as rooted `/usr/...`, not `usr\...` - relink: send the folder with forward slashes; backslashed, the relink VI walked nothing and reported success - refresh: join the refresh VI paths a component at a time - `` tokens: map to what Get System Directory.vi returns on Linux; keep global install records in /var/lib/lvpm, the cache in ~/.cache - Target Dir tails: accept either separator - launch: LF ini, own process group, and wait out TIME_WAIT on the VI Server port: a LabVIEW started while it lasts never serves VI Server - stage the relink VI in a per-user directory, not the shared /tmp The platform is chosen at compile time: each change is a cfg(windows) / cfg(not(windows)) pair whose Windows half is the previous code. Visible on Windows: two error messages name the full file path, and an expanded Target Dir uses backslashes throughout (the same path). Tests that pin Windows behaviour are cfg(windows), with Linux counterparts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JrYr5p1UVnscR92E5K9ZKf --- src/launch.rs | 88 ++++++++++++++++++--- src/main.rs | 40 ++++++++-- src/refresh.rs | 18 +++-- src/relink.rs | 62 +++++++++++---- src/target.rs | 204 ++++++++++++++++++++++++++++++++++++++++-------- src/viserver.rs | 87 ++++++++++++++++----- 6 files changed, 402 insertions(+), 97 deletions(-) diff --git a/src/launch.rs b/src/launch.rs index 8741a4b..e14b97f 100644 --- a/src/launch.rs +++ b/src/launch.rs @@ -2,7 +2,8 @@ //! //! LabVIEW reads its LVAddons locations once, at launch, from the ini file it //! was started with. So a venv is "activated" by starting a LabVIEW of its -//! own: a copy of the target's `LabVIEW.ini` with the venv added as an +//! own: a copy of the target's `LabVIEW.ini` (`labview.conf` on Linux, where +//! `-pref` works the same way) with the venv added as an //! `LVAddons.AdditionalLocations` entry and VI Server moved to the venv's //! port, handed over with `-pref`. Nothing in the installation changes, and //! the user's primary IDE — if one is open — keeps its own port and its own @@ -23,6 +24,12 @@ use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; +/// Line ending for the ini lvpm writes: LabVIEW's own on each platform. +#[cfg(windows)] +const EOL: &str = "\r\n"; +#[cfg(not(windows))] +const EOL: &str = "\n"; + /// Set `key=value` lines in an ini text: replace in place where the key exists /// (however it was spaced), append where it does not, and never write a key /// twice. Everything else is preserved as it was — the inherited ini is what @@ -49,14 +56,14 @@ pub fn override_keys(text: &str, keys: &[(&str, &str)]) -> String { Some(_) => continue, None => out.push_str(line), } - out.push_str("\r\n"); + out.push_str(EOL); } for (i, (k, v)) in keys.iter().enumerate() { if !seen[i] { out.push_str(k); out.push('='); out.push_str(v); - out.push_str("\r\n"); + out.push_str(EOL); } } out @@ -66,12 +73,22 @@ pub fn override_keys(text: &str, keys: &[(&str, &str)]) -> String { /// the venv mounted and VI Server moved to the venv's port. Regenerated every /// time, so the target's current settings are always what is inherited. pub fn write_ini(v: &Venv) -> Result { - let src = v.target.path.join("LabVIEW.ini"); - let text = std::fs::read_to_string(&src).with_context(|| format!("reading {}", src.display()))?; + let src = v.target.ini(); + let text = match std::fs::read_to_string(&src) { + Ok(text) => text, + // On Linux the file is per user and only exists once this user has + // started LabVIEW; until then the defaults are all there is to inherit. + Err(e) if e.kind() == std::io::ErrorKind::NotFound && cfg!(not(windows)) => { + format!("[LabVIEW]{EOL}") + } + Err(e) => return Err(e).with_context(|| format!("reading {}", src.display())), + }; // Verified unquoted; LabVIEW itself quotes path values that carry spaces, // so follow it exactly there and nowhere else. - let raw = v.dir.to_string_lossy().replace('/', "\\"); + let raw = v.dir.to_string_lossy().into_owned(); + #[cfg(windows)] + let raw = raw.replace('/', "\\"); let location = if raw.contains(' ') { format!("\"{raw}\"") } else { raw }; let port = v.port.to_string(); let out = override_keys( @@ -96,8 +113,9 @@ pub fn write_ini(v: &Venv) -> Result { /// Start the venv's LabVIEW with `ini`, optionally opening a project file. /// Detached: LabVIEW outlives lvpm by design. pub fn spawn(v: &Venv, ini: &Path, lvproj: Option<&Path>) -> Result { - let exe = v.target.path.join("LabVIEW.exe"); - ensure!(exe.is_file(), "no LabVIEW.exe in {}", v.target.path.display()); + let exe = v.target.exe(); + ensure!(exe.is_file(), "no {} in {}", exe.display(), v.target.path.display()); + wait_port_released(v.port); let mut cmd = Command::new(&exe); cmd.arg("-pref").arg(ini); if let Some(p) = lvproj { @@ -114,13 +132,56 @@ pub fn spawn(v: &Venv, ini: &Path, lvproj: Option<&Path>) -> Result { /// our stdout. An IDE that inherits that pipe holds it open for as long as it /// runs, and `lvpm launch | tail` never returns. So our std handles are made /// non-inheritable for the duration of the spawn, and restored afterwards. +/// +/// On Linux `Stdio::null()` is enough — the child's 0–2 are replaced, and Rust +/// opens its own descriptors close-on-exec — but the terminal's process group +/// is not: a child left in ours gets the SIGINT of a Ctrl+C meant for lvpm. So +/// there LabVIEW gets a group of its own. pub fn spawn_detached(cmd: &mut Command) -> std::io::Result { cmd.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()); #[cfg(windows)] let _keep_our_pipes = win::NoInherit::new(); + #[cfg(unix)] + std::os::unix::process::CommandExt::process_group(cmd, 0); cmd.spawn() } +/// Hold off starting a LabVIEW on `port` while a closed connection still +/// holds that port in TIME_WAIT, up to the kernel's fixed 60 s. +/// +/// LabVIEW on Linux binds its VI Server listener once, at startup, and +/// without `SO_REUSEADDR`: started while the previous instance's connections +/// are in TIME_WAIT, it runs without VI Server for the rest of its life and +/// never says so. Measured on 2026 Q3 — `lvpm launch` straight after an +/// install in a venv had closed its relink instance got a LabVIEW that never +/// listened. Linux-only, which is where it was measured. +#[cfg(target_os = "linux")] +pub fn wait_port_released(port: u16) { + const TIME_WAIT: &str = "06"; + let held = || { + ["/proc/net/tcp", "/proc/net/tcp6"].iter().any(|f| { + std::fs::read_to_string(f).unwrap_or_default().lines().skip(1).any(|l| { + let cols: Vec<&str> = l.split_whitespace().collect(); + cols.len() > 3 + && cols[3] == TIME_WAIT + && cols[1].rsplit(':').next().and_then(|p| u16::from_str_radix(p, 16).ok()) + == Some(port) + }) + }) + }; + if !held() { + return; + } + eprintln!("waiting for port {port} to leave TIME_WAIT, or LabVIEW would start without VI Server..."); + let started = Instant::now(); + while held() && started.elapsed() < Duration::from_secs(65) { + std::thread::sleep(Duration::from_secs(1)); + } +} + +#[cfg(not(target_os = "linux"))] +pub fn wait_port_released(_port: u16) {} + #[cfg(windows)] mod win { use std::os::windows::io::{AsRawHandle, RawHandle}; @@ -195,7 +256,11 @@ pub fn wait_ready(port: u16, budget: Duration) -> Result<()> { // A headless LabVIEW shows no dialogs; what would have been one is in // its log instead. let hint = if std::env::var_os("LV_RTE_HEADLESS").is_some() { - "(headless LabVIEW: see %TEMP%\\LabVIEW_*_headless_*_cur.txt for what went wrong)" + if cfg!(windows) { + "(headless LabVIEW: see %TEMP%\\LabVIEW_*_headless_*_cur.txt for what went wrong)" + } else { + "(headless LabVIEW: see /tmp/labview_*_headless_*_cur.txt for what went wrong)" + } } else { "(a dialog may be holding the IDE up — check its window)" }; @@ -265,17 +330,18 @@ mod tests { out, "[LabVIEW]\r\nserver.tcp.port=3400\r\nserver.tcp.acl=\"+*\"\r\n\ LVAddons.AdditionalLocations=C:\\r\\.project\r\n" + .replace("\r\n", EOL) ); } #[test] fn override_does_not_mistake_a_longer_key_for_a_shorter_one() { let out = override_keys("server.tcp.portfoo=1\n", &[("server.tcp.port", "2")]); - assert_eq!(out, "server.tcp.portfoo=1\r\nserver.tcp.port=2\r\n"); + assert_eq!(out, format!("server.tcp.portfoo=1{EOL}server.tcp.port=2{EOL}")); } #[test] fn override_of_an_empty_ini_is_just_the_keys() { - assert_eq!(override_keys("", &[("a", "1")]), "a=1\r\n"); + assert_eq!(override_keys("", &[("a", "1")]), format!("a=1{EOL}")); } } diff --git a/src/main.rs b/src/main.rs index 871c122..0e326e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -429,11 +429,32 @@ fn headless_roots(cli: &Cli, repo: &Path) -> Result { } /// The index cache is per-user, not per-target — the feeds are the same. +/// `%LOCALAPPDATA%\lvpm\cache` on Windows, `~/.cache/lvpm` (XDG) elsewhere. fn cache_dir() -> PathBuf { - let base = std::env::var("LOCALAPPDATA") + #[cfg(windows)] + let dir = std::env::var_os("LOCALAPPDATA").map(|d| PathBuf::from(d).join("lvpm").join("cache")); + #[cfg(not(windows))] + let dir = std::env::var_os("XDG_CACHE_HOME") + .filter(|d| !d.is_empty()) .map(PathBuf::from) - .unwrap_or_else(|_| std::env::temp_dir()); - base.join("lvpm").join("cache") + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache"))) + .map(|d| d.join("lvpm")); + dir.unwrap_or_else(|| std::env::temp_dir().join("lvpm").join("cache")) +} + +/// Where lvpm puts files a LabVIEW has to open by path: the relink VI, and a +/// PostUninstall hook that must outlive its package. `%TEMP%` is the user's +/// own on Windows; `/tmp` is shared on Linux, where a name another user took +/// first is one this user cannot write — so there it is the per-user cache. +fn work_dir() -> PathBuf { + #[cfg(windows)] + { + std::env::temp_dir() + } + #[cfg(not(windows))] + { + cache_dir() + } } fn cmd_start(cli: &Cli, wait: u64) -> Result<()> { @@ -633,8 +654,10 @@ fn uninstall_one( // hook VI itself, so it runs from a copy that outlives the uninstall. let post = match &before.post_uninstall_vi { Some(hook) if hooks => { - let tmp = std::env::temp_dir().join(format!("lvpm-{}-PostUninstall.vi", before.name)); - std::fs::copy(hook, &tmp) + let dir = work_dir(); + let tmp = dir.join(format!("lvpm-{}-PostUninstall.vi", before.name)); + std::fs::create_dir_all(&dir) + .and_then(|()| std::fs::copy(hook, &tmp)) .map(|_| tmp) .map_err(|e| println!("note: cannot stage PostUninstall.vi ({e}) — not running it")) .ok() @@ -1264,8 +1287,9 @@ fn hook_action_info( files: &[String], ) -> viserver::LvValue { use viserver::LvValue; - let paths: Vec = - files.iter().map(|f| LvValue::Path(f.replace('/', "\\"))).collect(); + // Manifests store forward slashes; a Windows LabVIEW wants its own. + let native = |f: &String| if cfg!(windows) { f.replace('/', "\\") } else { f.clone() }; + let paths: Vec = files.iter().map(|f| LvValue::Path(native(f))).collect(); let files_installed = LvValue::array(paths) .unwrap_or_else(|_| LvValue::empty_array(viserver::TD_PATH)); LvValue::Variant { @@ -1322,7 +1346,7 @@ fn run_relink( args: &RelinkArgs, work: &[(String, Vec)], ) -> Result<()> { - let vi = relink::locate_vi()?; + let vi = relink::locate_vi(&work_dir())?; // One walk covers every folder nested under it, so overlapping packages // share a run instead of relinking the same tree twice — 18 of the first // pass's 109 folders were nested repeats costing 17 of its 61 minutes. diff --git a/src/refresh.rs b/src/refresh.rs index 51ca4a9..8e063bf 100644 --- a/src/refresh.rs +++ b/src/refresh.rs @@ -23,11 +23,12 @@ //! //! | | VI | //! |---|---| -//! | palettes | `vi.lib\Palette API\Refresh Palettes.vi` | -//! | menus | `resource\plugins\PopupMenus\support\Refresh Menus.vi` | +//! | palettes | `vi.lib/Palette API/Refresh Palettes.vi` | +//! | menus | `resource/plugins/PopupMenus/support/Refresh Menus.vi` | //! -//! Present in every install checked (2015, 2025, 2026). So this module is only -//! plumbing: find the two VIs, run them, report what happened. +//! Present in every install checked (2015, 2025, 2026, and 2026 on Linux). So +//! this module is only plumbing: find the two VIs, run them, report what +//! happened. use crate::target::LvTarget; use crate::viserver::{self, Connection}; @@ -43,8 +44,8 @@ use std::time::{Duration, Instant}; /// wrapper around `Menus:Refresh` — a fair reason to expect its path to move /// one day, which is why a missing VI is reported rather than fatal. const REFRESH_VIS: [(&str, &str); 2] = [ - ("palettes", r"vi.lib\Palette API\Refresh Palettes.vi"), - ("menus", r"resource\plugins\PopupMenus\support\Refresh Menus.vi"), + ("palettes", "vi.lib/Palette API/Refresh Palettes.vi"), + ("menus", "resource/plugins/PopupMenus/support/Refresh Menus.vi"), ]; /// One refresh: what it was, and how it went. @@ -65,7 +66,10 @@ pub fn run(target: &LvTarget, timeout: Duration) -> Result> { let mut out = Vec::with_capacity(REFRESH_VIS.len()); for (what, rel) in REFRESH_VIS { - out.push(Outcome { what, result: run_one(&mut conn, target.path.join(rel)) }); + // Joined a component at a time: a separator inside one `join` is only + // a separator on Windows. + let vi = rel.split('/').fold(target.path.clone(), |p, c| p.join(c)); + out.push(Outcome { what, result: run_one(&mut conn, vi) }); } conn.close(); Ok(out) diff --git a/src/relink.rs b/src/relink.rs index a0bfeea..060236d 100644 --- a/src/relink.rs +++ b/src/relink.rs @@ -22,7 +22,7 @@ use crate::spec::Spec; use crate::target::Roots; use crate::viserver::{self, Connection, LvValue, VIRef}; use anyhow::{Context, Result, bail}; -use std::path::{Path, PathBuf}; +use std::path::{MAIN_SEPARATOR, MAIN_SEPARATOR_STR, Path, PathBuf}; use std::time::{Duration, Instant}; /// The control the folder goes into, and the indicator that says it finished. @@ -40,10 +40,13 @@ const LOG_INDICATOR: &str = "report log out"; /// ordinary filename character. So `C:/vi.lib/Foo` arrives as a single /// unparseable component, the relink walks nothing, and it reports success /// over an empty list. Install manifests store forward slashes, which is how -/// a whole relink pass came back reporting nothing at all. +/// a whole relink pass came back reporting nothing at all. On Linux it is the +/// other way round, so there the path goes as it is. fn lv_folder(folder: &Path) -> String { - let s = folder.to_string_lossy().replace('/', "\\"); - s.trim_end_matches('\\').to_string() + let s = folder.to_string_lossy(); + #[cfg(windows)] + let s = s.replace('/', "\\"); + s.trim_end_matches(MAIN_SEPARATOR).to_string() } /// How many saved files to name before summarising the rest — the same @@ -65,10 +68,13 @@ pub fn summarize_log(log: &str, folder: &Path) -> Vec { if saved.is_empty() { return vec!["saved nothing".to_string()]; } - let base = lv_folder(folder) + "\\"; + let base = lv_folder(folder) + MAIN_SEPARATOR_STR; let mut out = vec![format!("saved {} file(s)", saved.len())]; for p in saved.iter().take(SHOWN_SAVES) { + #[cfg(windows)] let rel = p.replace('/', "\\"); + #[cfg(not(windows))] + let rel = p.clone(); let rel = rel.strip_prefix(&base).unwrap_or(&rel); out.push(format!(" {rel}")); } @@ -94,15 +100,13 @@ const RELINK_VI_BYTES: &[u8] = include_bytes!(concat!( "/src/lv-src/relink-package.vi" )); -/// Materialize the bundled relink VI so LabVIEW can open it by path. +/// Materialize the bundled relink VI in `dir` so LabVIEW can open it by path. /// /// The VI is embedded in the executable at compile time, so the installed /// executable does not depend on the source checkout or a companion file. -pub fn locate_vi() -> Result { - let p = std::env::temp_dir().join(format!( - "lvpm-relink-package-{}.vi", - env!("CARGO_PKG_VERSION") - )); +pub fn locate_vi(dir: &Path) -> Result { + std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; + let p = dir.join(format!("lvpm-relink-package-{}.vi", env!("CARGO_PKG_VERSION"))); std::fs::write(&p, RELINK_VI_BYTES) .with_context(|| format!("materializing relink VI at {}", p.display()))?; Ok(p) @@ -431,6 +435,7 @@ mod tests { /// The report as LabVIEW writes it: a JSON array of absolute paths, which /// becomes a count and folder-relative names, capped past six. + #[cfg(windows)] #[test] fn summarize_log_counts_and_relativises() { let folder = Path::new(r"C:\Program Files\National Instruments\LabVIEW 2026\examples\DQMH"); @@ -453,6 +458,19 @@ mod tests { assert!(summarize_log("", folder).is_empty()); } + /// The same report from LabVIEW on Linux, whose paths use forward slashes. + #[cfg(not(windows))] + #[test] + fn summarize_log_relativises_linux_paths() { + let folder = Path::new("/usr/local/natinst/LabVIEW-2026-64/examples/DQMH/"); + let log = r#"["/usr/local/natinst/LabVIEW-2026-64/examples/DQMH/Libraries/A/A.lvlib", "/usr/local/natinst/LabVIEW-2026-64/examples/DQMH/B.lvlib"]"#; + assert_eq!( + summarize_log(log, folder), + vec!["saved 2 file(s)", " Libraries/A/A.lvlib", " B.lvlib"] + ); + assert_eq!(summarize_log("[]", folder), vec!["saved nothing"]); + } + /// The cross-package version of `collapse`: a folder covered by another /// package's folder joins that folder's run instead of getting its own, /// and exact duplicates merge. Order and per-folder attribution both @@ -460,22 +478,23 @@ mod tests { #[test] fn collapse_work_merges_overlapping_packages() { let work = vec![ - ("caraya".to_string(), vec![PathBuf::from(r"C:\lv\vi.lib\addons\Caraya")]), - ("h5".to_string(), vec![PathBuf::from(r"C:\lv\vi.lib\addons")]), - ("caraya_cli".to_string(), vec![PathBuf::from(r"C:\lv\vi.lib\addons\Caraya")]), - ("dqmh".to_string(), vec![PathBuf::from(r"C:\lv\project\DQMH")]), + ("caraya".to_string(), vec![PathBuf::from("/lv/vi.lib/addons/Caraya")]), + ("h5".to_string(), vec![PathBuf::from("/lv/vi.lib/addons")]), + ("caraya_cli".to_string(), vec![PathBuf::from("/lv/vi.lib/addons/Caraya")]), + ("dqmh".to_string(), vec![PathBuf::from("/lv/project/DQMH")]), ]; let plan = collapse_work(&work); assert_eq!(plan.len(), 2); - assert_eq!(plan[0].0, PathBuf::from(r"C:\lv\project\DQMH")); + assert_eq!(plan[0].0, PathBuf::from("/lv/project/DQMH")); assert_eq!(plan[0].1, ["dqmh"]); - assert_eq!(plan[1].0, PathBuf::from(r"C:\lv\vi.lib\addons")); + assert_eq!(plan[1].0, PathBuf::from("/lv/vi.lib/addons")); assert_eq!(plan[1].1, ["h5", "caraya", "caraya_cli"]); } /// Install manifests store forward slashes; LabVIEW needs backslashes, or /// it takes the whole path for one filename and relinks nothing while /// reporting success. A whole 101-folder pass came back empty this way. + #[cfg(windows)] #[test] fn folders_reach_labview_with_native_separators() { let m = "C:/Program Files/National Instruments/LabVIEW 2026/vi.lib/Delacor/Libraries"; @@ -489,6 +508,15 @@ mod tests { assert_eq!(lv_folder(Path::new("C:/vi.lib/Foo/")), r"C:\vi.lib\Foo"); } + /// On Linux the manifest's forward slashes are already LabVIEW's own; a + /// backslash there would be part of a file name. + #[cfg(not(windows))] + #[test] + fn folders_reach_linux_labview_as_they_are() { + let m = "/usr/local/natinst/LabVIEW-2026-64/vi.lib/Delacor/Libraries/"; + assert_eq!(lv_folder(Path::new(m)), m.trim_end_matches('/')); + } + fn f(paths: &[&str]) -> Vec { paths.iter().map(|s| s.to_string()).collect() } diff --git a/src/target.rs b/src/target.rs index 5893adb..aa3b7a4 100644 --- a/src/target.rs +++ b/src/target.rs @@ -3,7 +3,8 @@ //! A scratch prefix and a real LabVIEW installation differ only in where the //! roots point, so both go through `Roots`. That keeps the installer honest: //! the code path exercised against a sandbox is the same one that writes into -//! `C:\Program Files\National Instruments\LabVIEW 2026`. +//! `C:\Program Files\National Instruments\LabVIEW 2026` — or, on Linux, +//! `/usr/local/natinst/LabVIEW-2026-64`. use anyhow::{Result, bail}; use std::path::{Path, PathBuf}; @@ -29,8 +30,41 @@ impl LvTarget { pub fn label(&self) -> String { format!("LabVIEW {} ({}-bit) v{}", self.year(), self.bitness, self.version) } + + /// The executable lvpm starts. On Linux, `labview` is the install dir's + /// symlink to whichever edition is installed (`labviewprofull`, ...). + pub fn exe(&self) -> PathBuf { + self.path.join(EXE) + } + + /// The preferences file a LabVIEW started as [`LvTarget::exe`] reads: + /// VI Server's switch and port, and what a venv's ini is copied from. + /// + /// On Windows that is `LabVIEW.ini` beside the executable. On Linux it is + /// per user, `~/natinst/.config/LabVIEW-/`, and named after the name + /// LabVIEW was started as — `labview.conf` for `labview`, while the + /// `/usr/local/bin/labview64` symlink reads `labview64.conf`. It does not + /// exist before that user's first launch. + pub fn ini(&self) -> PathBuf { + #[cfg(windows)] + { + self.path.join("LabVIEW.ini") + } + #[cfg(not(windows))] + { + env_path("HOME", "/root") + .join("natinst/.config") + .join(format!("LabVIEW-{}", self.year())) + .join(format!("{EXE}.conf")) + } + } } +#[cfg(windows)] +const EXE: &str = "LabVIEW.exe"; +#[cfg(not(windows))] +const EXE: &str = "labview"; + #[cfg(windows)] pub fn detect() -> Result> { use winreg::RegKey; @@ -54,7 +88,7 @@ pub fn detect() -> Result> { continue; } let path = PathBuf::from(path.trim_end_matches(['\\', '/'])); - if !path.join("LabVIEW.exe").exists() { + if !path.join(EXE).exists() { continue; } // Several internal versions share one directory (26.0 and 26.3); @@ -72,24 +106,47 @@ pub fn detect() -> Result> { #[cfg(not(windows))] pub fn detect() -> Result> { - // LabVIEW on Linux lives under /usr/local/natinst/LabVIEW--64 - let mut out = Vec::new(); - if let Ok(rd) = std::fs::read_dir("/usr/local/natinst") { - for e in rd.flatten() { - let p = e.path(); - let n = p.file_name().unwrap_or_default().to_string_lossy().to_string(); - if let Some(rest) = n.strip_prefix("LabVIEW-") - && let Some(year) = rest.split('-').next().and_then(|y| y.parse::().ok()) - { - let bitness = if n.ends_with("-64") { 64 } else { 32 }; - out.push(LvTarget { version: (year - 2000) as f64, bitness, path: p }); - } + // LabVIEW on Linux lives under /usr/local/natinst/LabVIEW--64 unless + // its prefix was moved at install time; either way its package links + // /etc/natinst/labview--64 to the install's `etc`, where + // `labview.dir` names the install. A directory without the executable is + // what an uninstall leaves behind. + let listed = |dir: &str| std::fs::read_dir(dir).into_iter().flatten().flatten(); + let linked = listed("/etc/natinst") + .filter(|e| e.file_name().to_string_lossy().starts_with("labview-")) + .filter_map(|e| std::fs::read_to_string(e.path().join("labview.dir")).ok()) + .map(|d| PathBuf::from(d.trim())); + let mut out: Vec = Vec::new(); + for p in linked.chain(listed("/usr/local/natinst").map(|e| e.path())) { + let n = p.file_name().unwrap_or_default().to_string_lossy().to_string(); + if let Some(rest) = n.strip_prefix("LabVIEW-") + && let Some(year) = rest.split('-').next().and_then(|y| y.parse::().ok()) + && year > 2000 + && p.join(EXE).is_file() + && !out.iter().any(|t| t.path == p) + { + let bitness = if n.ends_with("-64") { 64 } else { 32 }; + let version = linux_version(&p).unwrap_or((year - 2000) as f64); + out.push(LvTarget { version, bitness, path: p }); } } out.sort_by(|a, b| b.version.partial_cmp(&a.version).unwrap_or(std::cmp::Ordering::Equal)); Ok(out) } +/// The internal version of a Linux installation, which its directory name +/// does not carry: a 2026 Q3 is 26.3, and that is what a package's +/// `Exclusive_LabVIEW_Version` gate is compared against. NI's uninstall +/// script in the install dir states it as `LV_MAJOR_VER=26` / `LV_MINOR_VER=3`. +#[cfg(not(windows))] +fn linux_version(install: &Path) -> Option { + let text = std::fs::read_to_string(install.join("readme/UNINSTALL")).ok()?; + let var = |key: &str| { + text.lines().find_map(|l| l.trim().strip_prefix(key)?.strip_prefix('=')?.trim().parse::().ok()) + }; + format!("{}.{}", var("LV_MAJOR_VER")?, var("LV_MINOR_VER")?).parse().ok() +} + /// Pick a target by year ("2026"), internal version ("26.3") or path. pub fn select(targets: &[LvTarget], want: &str) -> Result { let want = want.trim(); @@ -170,6 +227,7 @@ impl Roots { } } + #[cfg(windows)] pub fn labview(t: &LvTarget) -> Roots { let userprofile = env_path("USERPROFILE", "C:\\Users\\Default"); let program_files = if t.bitness == 32 { @@ -196,6 +254,29 @@ impl Roots { } } + /// The machine roots are what LabVIEW's own `Get System Directory.vi` + /// answers on Linux (2026 Q3, read over VI Server): the `` tokens + /// are that VI's directory types by name. + #[cfg(not(windows))] + pub fn labview(t: &LvTarget) -> Roots { + let home = env_path("HOME", "/root"); + Roots { + application: t.path.clone(), + temp: std::env::temp_dir(), + program_data: PathBuf::from("/usr/local"), + program_files: PathBuf::from("/usr/local"), + public_documents: PathBuf::from("/usr/local"), + user_documents: home.join("Documents"), + user_desktop: home.join("Desktop"), + user_appdata: home, + boot_volume: PathBuf::from("/"), + system_core: PathBuf::from("/usr/lib"), + scratch: None, + venv: None, + target: Some(t.clone()), + } + } + /// One package's addon inside a project venv. `//1` mirrors the /// LabVIEW install dir — that is what LVAddons overlays — so every /// LabVIEW-tree token lands inside the addon. The machine roots stay real: @@ -268,16 +349,23 @@ impl Roots { other => bail!("unknown Target Dir token {other:?} (from {target_dir:?})"), }; - Ok(if rest.is_empty() { base } else { base.join(rest) }) + // Specs spell the tail with either separator, and only Windows takes + // both: on Linux `addons\Foo` would be one directory of that name. + Ok(rest.split(['/', '\\']).filter(|c| !c.is_empty()).fold(base, |p, c| p.join(c))) } /// Manifests live beside the thing they describe: inside the venv or the - /// sandbox, in ProgramData keyed by target for a real install. + /// sandbox, and for a real install in machine-wide state keyed by target — + /// `%ProgramData%` on Windows, `/var/lib` on Linux. pub fn store_dir(&self) -> PathBuf { + #[cfg(windows)] + let machine = env_path("ProgramData", "C:\\ProgramData"); + #[cfg(not(windows))] + let machine = PathBuf::from("/var/lib"); match (&self.venv, &self.scratch, &self.target) { (Some(v), _, _) => v.join(".lvpm").join("installed"), (None, Some(p), _) => p.join(".lvpm").join("installed"), - (None, None, Some(t)) => env_path("ProgramData", "C:\\ProgramData") + (None, None, Some(t)) => machine .join("lvpm") .join(t.key()) .join("installed"), @@ -359,52 +447,102 @@ mod tests { } } + /// An install dir and a repo, as each platform spells them. + #[cfg(windows)] + const LV: &str = r"C:\Program Files\National Instruments\LabVIEW 2026"; + #[cfg(not(windows))] + const LV: &str = "/usr/local/natinst/LabVIEW-2026-64"; + #[cfg(windows)] + const REPO: &str = r"C:\repo"; + #[cfg(not(windows))] + const REPO: &str = "/home/dev/repo"; + #[test] fn labview_roots_hang_off_the_install_dir() { - let t = LvTarget { - version: 26.3, - bitness: 64, - path: PathBuf::from(r"C:\Program Files\National Instruments\LabVIEW 2026"), - }; + let t = LvTarget { version: 26.3, bitness: 64, path: PathBuf::from(LV) }; assert_eq!(t.year(), 2026); assert_eq!(t.key(), "LabVIEW-2026-64bit"); let r = Roots::labview(&t); assert_eq!( r.expand("/addons/HSE").unwrap(), - Path::new(r"C:\Program Files\National Instruments\LabVIEW 2026\vi.lib\addons\HSE") + Path::new(LV).join("vi.lib").join("addons").join("HSE") ); assert_eq!( r.expand("/Categories").unwrap(), - Path::new(r"C:\Program Files\National Instruments\LabVIEW 2026\menus\Categories") + Path::new(LV).join("menus").join("Categories") ); } #[test] fn project_roots_put_labview_tokens_in_the_addon_and_leave_machine_roots_alone() { - let t = LvTarget { version: 26.3, bitness: 64, path: PathBuf::from(r"C:\LV2026") }; - let venv = Path::new(r"C:\repo\.project"); + let t = LvTarget { version: 26.3, bitness: 64, path: PathBuf::from(LV) }; + let venv = &Path::new(REPO).join(".project"); let r = Roots::project(venv, &t, "oglib_error"); + let addon = venv.join("oglib_error").join("1"); assert_eq!( r.expand("/_OpenG.lib/error").unwrap(), - Path::new(r"C:\repo\.project\oglib_error\1\vi.lib\_OpenG.lib\error") + addon.join("vi.lib").join("_OpenG.lib").join("error") ); - assert_eq!(r.expand("").unwrap(), Path::new(r"C:\repo\.project\oglib_error\1")); - assert!(!r.expand("").unwrap().starts_with(r"C:\repo")); - assert!(!r.expand("").unwrap().starts_with(r"C:\repo")); + assert_eq!(r.expand("").unwrap(), addon); + assert!(!r.expand("").unwrap().starts_with(REPO)); + assert!(!r.expand("").unwrap().starts_with(REPO)); // Nothing LabVIEW-class may leave the venv; machine roots are not // written to at all in venv mode, and the guard is what says so. r.check_contained(&r.expand("/Categories").unwrap()).unwrap(); assert!(r.check_contained(&r.expand("").unwrap()).is_err()); - assert_eq!(r.store_dir(), Path::new(r"C:\repo\.project\.lvpm\installed")); + assert_eq!(r.store_dir(), venv.join(".lvpm").join("installed")); assert_eq!(Roots::project_store(venv, &t).store_dir(), r.store_dir()); - assert_eq!(Roots::project_store(venv, &t).application, venv); - assert_eq!(r.venv(), Some(venv)); + assert_eq!(&Roots::project_store(venv, &t).application, venv); + assert_eq!(r.venv(), Some(venv.as_path())); assert!(r.target.is_some()); } + /// On Linux the `` tokens land where LabVIEW's Get System + /// Directory.vi says, and a global install's manifests in /var/lib — not + /// in a `C:\ProgramData` relative to the working directory, which is what + /// the Windows fallbacks amount to there. + #[cfg(not(windows))] + #[test] + fn linux_roots_are_the_ones_labview_reports() { + let t = LvTarget { version: 26.3, bitness: 64, path: PathBuf::from(LV) }; + let r = Roots::labview(&t); + let home = env_path("HOME", "/root"); + assert_eq!(r.expand("/x").unwrap(), home.join("Documents/x")); + assert_eq!(r.expand("").unwrap(), home); + assert_eq!(r.expand("").unwrap(), Path::new("/usr/local")); + assert_eq!(r.expand("/ci").unwrap(), Path::new("/ci")); + assert_eq!(r.expand("").unwrap(), Path::new("/usr/lib")); + assert_eq!(r.store_dir(), Path::new("/var/lib/lvpm/LabVIEW-2026-64bit/installed")); + assert_eq!(t.exe(), Path::new(LV).join("labview")); + assert_eq!(t.ini(), home.join("natinst/.config/LabVIEW-2026/labview.conf")); + } + + #[cfg(not(windows))] + #[test] + fn linux_version_comes_from_the_uninstall_script() { + let dir = std::env::temp_dir().join(format!("lvpm-target-test-{}", std::process::id())); + std::fs::create_dir_all(dir.join("readme")).unwrap(); + assert_eq!(linux_version(&dir), None); + std::fs::write( + dir.join("readme/UNINSTALL"), + "# LabVIEW 2026 Q3 uninstallation script.\nLV_MAJOR_VER=26\nLV_MINOR_VER=3\nLV_UPDATE_VER=0\n", + ) + .unwrap(); + assert_eq!(linux_version(&dir), Some(26.3)); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn a_target_dir_may_use_either_separator() { + let r = Roots::scratch(Path::new("/tmp/box")); + let want = Path::new("/tmp/box/LabVIEW").join("vi.lib").join("addons").join("Foo"); + assert_eq!(r.expand("/addons/Foo").unwrap(), want); + assert_eq!(r.expand("\\addons\\Foo\\").unwrap(), want); + } + #[test] fn classify_sorts_tokens_by_where_they_point() { assert_eq!(Roots::classify("/addons/Foo").unwrap(), TokenClass::LabView); diff --git a/src/viserver.rs b/src/viserver.rs index 334f14b..69b8175 100644 --- a/src/viserver.rs +++ b/src/viserver.rs @@ -32,9 +32,10 @@ use std::net::{Ipv4Addr, SocketAddr, TcpStream}; use std::path::Path; use std::time::Duration; -/// Read a `key=value` token out of a target's `LabVIEW.ini`. +/// Read a `key=value` token out of a target's `LabVIEW.ini` (`labview.conf` +/// on Linux — see [`LvTarget::ini`]). fn ini_token(target: &LvTarget, key: &str) -> Option { - let text = std::fs::read_to_string(target.path.join("LabVIEW.ini")).ok()?; + let text = std::fs::read_to_string(target.ini()).ok()?; for line in text.lines() { let line = line.trim(); if let Some(rest) = line.strip_prefix(key) @@ -58,13 +59,16 @@ pub fn vi_server_port(target: &LvTarget) -> u16 { pub fn check_vi_server(target: &LvTarget) -> Result { let enabled = ini_token(target, "server.tcp.enabled") .map(|v| v.eq_ignore_ascii_case("true")) - // Absent means LabVIEW's own default, which is on. - .unwrap_or(true); + // Absent means LabVIEW's own default: on for Windows, off on Linux — + // a 2026 Q3 started on an ini without the key never listens. NI's + // Linux container image sets it for that reason. + .unwrap_or(cfg!(windows)); if !enabled { bail!( - "VI Server is disabled for {} (server.tcp.enabled=False in LabVIEW.ini).\n\ - Enable it in Tools >> Options >> VI Server.", - target.label() + "VI Server is disabled for {} (server.tcp.enabled is not True in {}).\n\ + Enable it in Tools >> Options >> VI Server, or add server.tcp.enabled=True there.", + target.label(), + target.ini().display() ); } Ok(vi_server_port(target)) @@ -99,10 +103,11 @@ pub fn ensure_vi_server(target: &LvTarget, wait: Duration) -> Result { return Ok(port); } - let exe = target.path.join("LabVIEW.exe"); + let exe = target.exe(); if !exe.is_file() { - bail!("no LabVIEW.exe in {}", target.path.display()); + bail!("no {} in {}", exe.file_name().unwrap_or_default().to_string_lossy(), target.path.display()); } + crate::launch::wait_port_released(port); eprintln!("starting {} and waiting for VI Server on port {port}...", target.label()); // Detached child: LabVIEW outlives lvpm by design — and must not keep // lvpm's own pipes open while it does (see `launch::spawn_detached`). @@ -952,15 +957,27 @@ impl<'a> Reader<'a> { let n = body.take(1)?[0] as usize; parts.push(String::from_utf8_lossy(body.take(n)?).into_owned()); } - // A drive letter comes back as a bare component: "C" is "C:". - if kind == 0 - && let Some(first) = parts.first_mut() - && first.len() == 1 - && first.chars().all(|c| c.is_ascii_alphabetic()) + #[cfg(windows)] { - first.push(':'); + // A drive letter comes back as a bare component: "C" is "C:". + if kind == 0 + && let Some(first) = parts.first_mut() + && first.len() == 1 + && first.chars().all(|c| c.is_ascii_alphabetic()) + { + first.push(':'); + } + Ok(parts.join("\\")) + } + // One root and no drives: "/usr/local" comes back as "usr", "local" + // and must be rooted again, or it reads as a relative path. + #[cfg(not(windows))] + { + Ok(match kind { + 0 => format!("/{}", parts.join("/")), + _ => parts.join("/"), + }) } - Ok(parts.join("\\")) } /// Decode the value at table entry `idx`. `depth` bounds the recursion: a @@ -1060,6 +1077,8 @@ pub struct Connection { /// encoder serves both the wire protocol and any future offline path rewriting: /// `"PTH0" | u32 byte length | u32 component count | pascal components`, with an /// absolute Windows path contributing its drive letter as the first component. +/// A Linux path is the same record without one: `/usr/local` is `usr`, +/// `local` (verified against LabVIEW 2026 Q3 on Linux). pub fn encode_pth0(path: &Path) -> Result> { let s = path.to_string_lossy().replace('/', "\\"); let mut parts: Vec = Vec::new(); @@ -1586,12 +1605,23 @@ mod tests { assert_eq!(got.to_string(), r#"["Hello 1", "Hello 2"]"#); } + /// A Windows path as this platform spells the same place: unchanged on + /// Windows; elsewhere the drive goes and the separators turn, so the round + /// trips below check the decoder the platform actually runs. + fn native(windows_path: &str) -> String { + if cfg!(windows) { + return windows_path.to_string(); + } + let rest = windows_path.split_once(':').map_or(windows_path, |(_, r)| r); + rest.replace('\\', "/") + } + /// `encode_pth0` is already pinned against a capture, so decoding is /// checked by round-tripping the very record LabVIEW's own client sent. #[test] fn decodes_a_pth0_record() { - let path = r"C:\Git\lvpm\tools\Set VI Server Logging.vi"; - let encoded = encode_pth0(Path::new(path)).unwrap(); + let path = native(r"C:\Git\lvpm\tools\Set VI Server Logging.vi"); + let encoded = encode_pth0(Path::new(&path)).unwrap(); assert_eq!(Reader::new(&encoded).pth0().unwrap(), path); // "Not A Path" carries kind 2 and no components. @@ -1602,6 +1632,21 @@ mod tests { assert!(Reader::new(b"XXXX\x00\x00\x00\x04\x00\x00\x00\x00").pth0().is_err()); } + /// What LabVIEW 2026 Q3 on Linux answered for `Application Directory.vi`: + /// the same record as on Windows, minus a drive. Read back as `usr\local\...` + /// it would name a relative path under the working directory. + #[cfg(not(windows))] + #[test] + fn decodes_a_linux_pth0_record_as_a_rooted_path() { + let rec = b"PTH0\x00\x00\x00\x13\x00\x00\x00\x03\x03usr\x05local\x04x.vi"; + assert_eq!(Reader::new(rec).pth0().unwrap(), "/usr/local/x.vi"); + let root = b"PTH0\x00\x00\x00\x04\x00\x00\x00\x00"; + assert_eq!(Reader::new(root).pth0().unwrap(), "/"); + let relative = b"PTH0\x00\x00\x00\x09\x00\x01\x00\x01\x04x.vi"; + assert_eq!(Reader::new(relative).pth0().unwrap(), "x.vi"); + assert_eq!(encode_pth0(Path::new("/usr/local/x.vi")).unwrap(), rec); + } + /// The action-info variant a hook VI's `Variant` control receives: named /// attributes after the data, each an lv-string name and a complete /// flattened variant. Derived from LabVIEW's flattened-data layout; the @@ -1615,7 +1660,7 @@ mod tests { ("Package Name".into(), LvValue::Str("delacor_lib_dqmh_documentation".into())), ( "Files Installed".into(), - LvValue::array(vec![LvValue::Path(r"C:\lv\vi.lib\a.vi".into())]).unwrap(), + LvValue::array(vec![LvValue::Path(native(r"C:\lv\vi.lib\a.vi"))]).unwrap(), ), ], }; @@ -1633,8 +1678,8 @@ mod tests { #[test] fn round_trips_an_array_of_paths() { let v = LvValue::array(vec![ - LvValue::Path(r"C:\Program Files\National Instruments\LabVIEW 2026\vi.lib\a.vi".into()), - LvValue::Path(r"C:\Program Files\National Instruments\LabVIEW 2026\vi.lib\b.vi".into()), + LvValue::Path(native(r"C:\Program Files\National Instruments\LabVIEW 2026\vi.lib\a.vi")), + LvValue::Path(native(r"C:\Program Files\National Instruments\LabVIEW 2026\vi.lib\b.vi")), ]) .unwrap(); let flat = variant(&v).unwrap(); From 760c5de19a71481b68fa2d197769b53bd91ec2b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 22:45:25 +0000 Subject: [PATCH 02/12] test(linux): end-to-end run in NI's Linux container scripts/linux-e2e.sh builds test packages from tools/*.vi and checks detection, a headless install with relink and hooks, refresh, relink, run-hooks, uninstall and a venv whose LVAddons overlay is proven by a caller that runs on the venv's LabVIEW and is broken on a fresh plain one. LVPM= uses a prebuilt static lvpm instead of building one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JrYr5p1UVnscR92E5K9ZKf --- scripts/linux-e2e.sh | 171 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100755 scripts/linux-e2e.sh diff --git a/scripts/linux-e2e.sh b/scripts/linux-e2e.sh new file mode 100755 index 0000000..9e6476a --- /dev/null +++ b/scripts/linux-e2e.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +# End-to-end check of lvpm against LabVIEW for Linux, in NI's container image. +# +# Builds a static lvpm, makes two test packages from the repo's own VIs in +# tools/, and drives every LabVIEW-facing path in a throwaway container: +# detection, a headless global install with relink and hooks, palette and menu +# refresh, relink, uninstall, and a venv install whose overlay is proven by a +# VI that links into running on the venv's LabVIEW and not on the +# plain one. No package index is contacted; everything comes from a local +# folder. See docs/linux.md for what each step is evidence of. +# +# scripts/linux-e2e.sh [image] default: nationalinstruments/labview:latest-linux +# +# Needs Docker, rustup's x86_64-unknown-linux-musl target, musl-gcc, python3; +# with LVPM set to a static lvpm, only Docker and python3. +set -euo pipefail + +IMAGE=${1:-nationalinstruments/labview:latest-linux} +ROOT=$(cd "$(dirname "$0")/.." && pwd) +WORK=$(mktemp -d) +NAME=lvpm-e2e-$$ +trap 'docker rm -f "$NAME" >/dev/null 2>&1 || true; rm -rf "$WORK"' EXIT + +# NI's image is Ubuntu 22.04 (glibc 2.35); a static binary runs there whatever +# the build host's glibc. +if [ -n "${LVPM:-}" ]; then + cp "$LVPM" "$WORK/lvpm" +else + CC_x86_64_unknown_linux_musl=musl-gcc \ + cargo build --release --target x86_64-unknown-linux-musl --manifest-path "$ROOT/Cargo.toml" + cp "$ROOT/target/x86_64-unknown-linux-musl/release/lvpm" "$WORK/lvpm" +fi +chmod +x "$WORK/lvpm" +# In a folder of its own: LabVIEW searches a missing subVI under the top-level +# VI's folder, and the venv's copy must not be found that way. +mkdir "$WORK/caller" +cp "$ROOT/tools/Test - Relink Packages E2E.vi" "$WORK/caller/caller.vi" + +python3 - "$ROOT/tools" "$WORK" <<'PY' +import sys, zipfile, os +tools, work = sys.argv[1], sys.argv[2] +os.makedirs(f"{work}/pkgs") + +def vip(name, spec, members): + with zipfile.ZipFile(f"{work}/pkgs/{name}-1.0.0.1.vip", "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("spec", f'[Package]\nName="{name}"\nVersion="1.0.0.1"\n\n' + spec) + for arc, src in members: + if src is None: + z.writestr(arc, "lvpm Linux test\n") + else: + z.write(f"{tools}/{src}", arc) + +# Files into and one location, plus both install hooks. +vip("lvpm_linux_test", '''[Script VIs] +PreInstall="PreInstall.vi" +PostInstall="PostInstall.vi" + +[File Group 0] +Target Dir="/lvpm_linux_test" +Replace Mode="Always" +Num Files=3 +File 0="Relink Packages.vi" +File 1="Test - Relink Packages E2E.vi" +File 2="E2E Call VI.vi" + +[File Group 1] +Target Dir="/lvpm_linux_test" +Replace Mode="Always" +Num Files=1 +File 0="readme.txt" +''', [ + ("File Group 0/Relink Packages.vi", "Relink Packages.vi"), + ("File Group 0/Test - Relink Packages E2E.vi", "Test - Relink Packages E2E.vi"), + ("File Group 0/E2E Call VI.vi", "E2E Call VI.vi"), + ("File Group 1/readme.txt", None), + ("PreInstall.vi", "E2E Call VI.vi"), + ("PostInstall.vi", "E2E Call VI.vi"), +]) + +# A caller and its subVI in two folders: what the relink walk must find. The +# second Target Dir is spelled with backslashes, as some specs do. +vip("lvpm_linux_relink_test", '''[File Group 0] +Target Dir="/lvpm_linux_relink_test" +Replace Mode="Always" +Num Files=1 +File 0="Test - Relink Packages E2E.vi" + +[File Group 1] +Target Dir="\\lvpm_linux_relink_test\\sub" +Replace Mode="Always" +Num Files=1 +File 0="Relink Packages.vi" +''', [ + ("File Group 0/Test - Relink Packages E2E.vi", "Test - Relink Packages E2E.vi"), + ("File Group 1/Relink Packages.vi", "Relink Packages.vi"), +]) + +for d, name in (("global", "linux-e2e-global"), ("venv", "linux-e2e-venv")): + os.makedirs(f"{work}/{d}") + open(f"{work}/{d}/lvpm.toml", "w").write(f'''[project] +name = "{name}" +labview = "2026" + +[sources] +local = "../pkgs" +defaults = false + +[dependencies] +lvpm_linux_test = "*" +''') +PY + +docker run -d --init --name "$NAME" -e LV_RTE_HEADLESS=1 -v "$WORK:/work" "$IMAGE" sleep infinity >/dev/null +docker exec -i "$NAME" bash -s <<'SH' +set -u +L=/work/lvpm +LV=$($L targets | awk '{print $NF; exit}') +fails=0 +check() { if eval "$2"; then echo "ok $1"; else echo "FAIL $1"; fails=$((fails + 1)); fi; } +listening() { awk 'NR>1 && $4=="0A" {print $2}' /proc/net/tcp | grep -qi ":$(printf '%04X' "$1")$"; } +run() { echo "\$ lvpm $*" >> /work/log; "$L" "$@" >> /work/log 2>&1; } + +check "targets: detects LabVIEW 2026 with its minor version" \ + '$L targets | grep -Eq "LabVIEW 2026 \(64-bit\) +v26\.[0-9] .*/usr/local/natinst/LabVIEW-2026-64"' + +cd /work/global +check "headless global install with --relink --hooks" 'run install --relink --hooks' +check "files in vi.lib and in (\$HOME/Documents)" \ + '[ -f "$LV/vi.lib/lvpm_linux_test/E2E Call VI.vi" ] && [ -f "$HOME/Documents/lvpm_linux_test/readme.txt" ]' +check "both hooks ran" 'grep -q "pre-install ok" /work/log && grep -Eq "lvpm_linux_test \.\.\. ok" /work/log' +check "install records kept in /var/lib/lvpm" '[ -f /var/lib/lvpm/LabVIEW-2026-64bit/installed/lvpm_linux_test.json ]' +check "a backslashed Target Dir lands in real subfolders" \ + 'run install lvpm_linux_relink_test --relink && [ -f "$LV/vi.lib/lvpm_linux_relink_test/sub/Relink Packages.vi" ]' +check "list shows both relinked" '[ "$($L list 2>/dev/null | grep -c relinked)" = 2 ]' +check "run-hooks reruns a PostInstall" 'run run-hooks lvpm_linux_test --labview-version 2026' + +VI=$(ls "$HOME"/.cache/lvpm/lvpm-relink-package-*.vi) +check "relink VI walks a Linux folder (finds caller and subVI)" \ + '$L vi-run "$VI" --labview-version 2026 --set "Folder to relink=str:$LV/vi.lib/lvpm_linux_relink_test" --get "all lv items" 2>&1 | grep -q "sub/Relink Packages.vi"' +out=$($L refresh --labview-version 2026 2>&1) +check "refresh: both LabVIEW refresh VIs found and run" \ + 'grep -Eq "palettes +ok" <<<"$out" && grep -Eq "menus +ok" <<<"$out"' +check "relink --all" 'run relink --all' +check "vi-save of a caller relinks it to " 'run vi-save /work/caller/caller.vi --labview-version 2026' +check "uninstall --all leaves vi.lib clean" \ + 'run uninstall --all && ! ls "$LV/vi.lib" | grep -q lvpm_ && [ ! -e "$HOME/Documents/lvpm_linux_test" ]' + +# The plain LabVIEW has had the package's VIs in memory, and a caller links a +# subVI of the same name that is still loaded; only a fresh one is a fair +# control for the overlay check below. +pkill -x labview; for _ in $(seq 30); do pgrep -x labview >/dev/null || break; sleep 1; done + +cd /work/venv +check "venv create" 'run venv create' +check "venv install with relink in a LabVIEW started with -pref" 'run install --relink' +check "venv ini mounts the venv with forward slashes" \ + 'grep -qx "LVAddons.AdditionalLocations=/work/venv/.project" .project/.lvpm/labview.ini' +PORT=$(python3 -c 'import json; print(json.load(open(".project/.lvpm/venv.json"))["port"])') +run launch +for _ in $(seq 60); do listening "$PORT" && break; sleep 2; done +check "launch straight after install still gets VI Server (TIME_WAIT)" 'listening "$PORT"' +for _ in $(seq 30); do $L vi-probe "$LV/vi.lib/Utility/error.llb/General Error Handler.vi" >/dev/null 2>&1 && break; sleep 2; done +check "overlay: caller linked into runs on the venv's LabVIEW" \ + '$L vi-run /work/caller/caller.vi --timeout 60 >/dev/null 2>&1' +check "control: the same caller is broken on a fresh plain LabVIEW" \ + 'run start --global --labview-version 2026 && + $L vi-run /work/caller/caller.vi --global --labview-version 2026 --timeout 60 2>&1 | grep -q "error 1003"' + +echo +[ "$fails" = 0 ] && echo "all checks passed" || { echo "$fails check(s) failed; lvpm's output:"; cat /work/log; exit 1; } +SH From 02b8631c365797869c75dfd547319a28d54654c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 22:45:26 +0000 Subject: [PATCH 03/12] ci: build and test per platform, publish the executables After nominal-io/reqwest-labview's build workflow: one job per platform on its own runner (windows-latest MSVC, ubuntu-latest static musl), each running cargo test and a release build and uploading its executable; a collect job putting both in one artifact named for version and commit; and linux-e2e running scripts/linux-e2e.sh in nationalinstruments/labview:2026q3-linux. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JrYr5p1UVnscR92E5K9ZKf --- .github/workflows/build.yml | 133 ++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..947ba35 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,133 @@ +# Build and test lvpm on each platform's own runner, publish the executables, +# and run lvpm against LabVIEW for Linux in NI's container (docs/linux.md). +name: build + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + # Windows 64-bit: the MSVC build the releases ship + - os: windows-latest + target: x86_64-pc-windows-msvc + name: windows-x64 + exe: lvpm.exe + + # Linux 64-bit, static: NI's image is Ubuntu 22.04, older than the + # runner's glibc + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + name: linux-x64 + exe: lvpm + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install musl tools + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y musl-tools + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo index + uses: actions/cache@v4 + with: + path: ~/.cargo/git + key: ${{ runner.os }}-cargo-git-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache target directory + uses: actions/cache@v4 + with: + path: target + key: ${{ runner.os }}-${{ matrix.target }}-target-${{ hashFiles('Cargo.lock') }} + + # On the runner's own platform: Windows tests on Windows. + - name: Unit tests + run: cargo test + + - name: Build release + run: cargo build --release --target ${{ matrix.target }} + env: + CC_x86_64_unknown_linux_musl: musl-gcc + + - name: Upload executable + uses: actions/upload-artifact@v4 + with: + name: lvpm-${{ matrix.name }} + path: target/${{ matrix.target }}/release/${{ matrix.exe }} + if-no-files-found: error + + # Both executables in one download, named for the version and commit, to + # hand to testers. The Linux one is a tarball: an artifact zip does not keep + # the executable bit. + collect: + needs: build + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download executables + uses: actions/download-artifact@v4 + with: + path: executables + + - name: Name them + run: | + version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1) + mkdir dist + cp executables/lvpm-windows-x64/lvpm.exe "dist/lvpm-$version-windows-x64.exe" + chmod +x executables/lvpm-linux-x64/lvpm + tar -czf "dist/lvpm-$version-linux-x64.tar.gz" -C executables/lvpm-linux-x64 lvpm + echo "BUNDLE=lvpm-$version-${GITHUB_SHA::7}" >> "$GITHUB_ENV" + ls -l dist + + - name: Upload combined artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ env.BUNDLE }} + path: dist/ + if-no-files-found: error + + linux-e2e: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download the Linux executable + uses: actions/download-artifact@v4 + with: + name: lvpm-linux-x64 + path: dist + + # Pinned, so a new image is a change someone makes on purpose. + - name: lvpm against LabVIEW 2026 for Linux + run: LVPM=dist/lvpm scripts/linux-e2e.sh nationalinstruments/labview:2026q3-linux From b94dd9081241d76bf895870ba0c24b4cd1cc5327 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 22:45:26 +0000 Subject: [PATCH 04/12] docs(linux): what differs on Linux, and what was measured docs/linux.md: building, how Windows stays as it was, the Windows/Linux differences and what lvpm does about each, the token table, LabVIEWCLI on Linux, what was verified, CI, and what Linux still needs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JrYr5p1UVnscR92E5K9ZKf --- README.md | 16 +++- docs/linux.md | 155 +++++++++++++++++++++++++++++++++++++ docs/roadmap.md | 6 +- docs/vi-server-protocol.md | 8 +- 4 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 docs/linux.md diff --git a/README.md b/README.md index c8551c5..1c54639 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,10 @@ Not affiliated with JKI or NI. Working, in active development; see ## Install lvpm -Windows 10/11 with LabVIEW 2020 or later. Linux target detection exists but -is untested. +Windows 10/11 with LabVIEW 2020 or later. On Linux, lvpm is verified against +LabVIEW 2026 Q3 in NI's container image; take `lvpm--linux-x64.tar.gz` +from the artifacts of a CI run, or build it from source, and see +[docs/linux.md](docs/linux.md) for what differs there. ### With the install script @@ -359,6 +361,11 @@ differently in the container than on your machine, `--hooks` is the first thing to try; it starts a headless LabVIEW for them. `--relink` likewise when the job's output *is* relinked packages. +NI's Linux image (`nationalinstruments/labview:2026q3-linux`) works the same +way; [docs/linux.md](docs/linux.md) has the differences, and +[.github/workflows/build.yml](.github/workflows/build.yml) runs lvpm in it on +every push. + Two container facts worth knowing. LabVIEW allows one mode at a time per machine, headless or IDE, so a headless job cannot share a host with an open IDE. And any LabVIEW that is started (by `--hooks`, `--relink`, or the job @@ -398,7 +405,9 @@ fetches them again. but runs to completion reports as ok. - A failure part-way through unpacking leaves files behind with no manifest, and one failed download aborts the rest of the plan. -- Windows is the tested platform. +- Windows is the tested platform. Linux is verified against LabVIEW 2026 Q3 + in NI's container image only, and does not yet honour a package's + `Exclusive_OS` ([docs/linux.md](docs/linux.md)). - In a venv, install hooks are extracted but never run (they act on the LabVIEW installation, not on an overlay), and file groups aimed at machine locations (``, ``) are skipped. Both are recorded in the @@ -426,6 +435,7 @@ fetches them again. | `version.rs` | Package version ordering: OGPT `version-release`, VIPM's four-part form; not semver | | `src/lv-src/relink-package.vi` | The relink VI embedded into the executable at build time (LabVIEW 2020) | | `scripts/install.ps1` | Download, verify and install the latest release | +| `scripts/linux-e2e.sh` | End-to-end check against LabVIEW for Linux in NI's container image | ## License diff --git a/docs/linux.md b/docs/linux.md new file mode 100644 index 0000000..64596cf --- /dev/null +++ b/docs/linux.md @@ -0,0 +1,155 @@ +# LabVIEW on Linux + +lvpm builds and runs on Linux, and drives LabVIEW for Linux the way it drives +the Windows IDE: files copied, then relinked and hooks run over VI Server. +Everything below was measured on 2026-09-26 against NI's container image +`nationalinstruments/labview:2026q3-linux` (LabVIEW 2026 Q3, Ubuntu 22.04). +[scripts/linux-e2e.sh](../scripts/linux-e2e.sh) repeats every check in a +throwaway container in about a minute and a half, and [CI](#ci) runs it. + +## Building + +```console +$ cargo build --release # target/release/lvpm, for this machine's glibc +$ cargo test +$ rustup target add x86_64-unknown-linux-musl +$ CC_x86_64_unknown_linux_musl=musl-gcc cargo build --release --target x86_64-unknown-linux-musl +``` + +Nothing in the dependency tree needs OpenSSL: reqwest's `default-tls` is +rustls on aws-lc, which needs only a C compiler (`musl-gcc` for the static +build). A binary built on a current distribution does not start in NI's image +(`GLIBC_2.38 not found`; the image has 2.35), so the static musl build is the +one to ship. + +### Windows stays as it was + +The platform is chosen when lvpm is compiled, not when it runs: each +difference below is a `#[cfg(windows)]` / `#[cfg(not(windows))]` pair, and +the Windows half is the code that was there before. What a Windows user can +see changed is small: the "VI Server is disabled" and "no LabVIEW.exe" +messages name the full path of the file, and a `Target Dir` such as +`/addons/Foo` now expands to `...\vi.lib\addons\Foo` instead of +`...\vi.lib\addons/Foo` (the same path either way). Tests that pin +Windows behaviour are `cfg(windows)`, with Linux counterparts, and run on a +Windows runner in [CI](#ci). + +## What differs + +| | Windows | Linux, measured | lvpm on Linux | +|---|---|---|---| +| Installation | registry; `LabVIEW.exe` | `/usr/local/natinst/LabVIEW--64`, Q1 and Q3 of a year sharing it; `/etc/natinst/labview--64` links to its `etc`, whose `labview.dir` names it. `labview` in it is a symlink to the edition's binary (`labviewprofull`) | reads `/etc/natinst`, scans `/usr/local/natinst`, requires `labview` | +| Internal version | registry key (`26.3`) | not in the directory name; `readme/UNINSTALL` in the install sets `LV_MAJOR_VER=26`, `LV_MINOR_VER=3` | reads it; the year when it is missing | +| Preferences | `LabVIEW.ini` beside the exe | `~/natinst/.config/LabVIEW-/.conf`, per user, named after the name LabVIEW was started as (`labview.conf` for `labview`). Absent until the user's first launch; LabVIEW adds the `[LabVIEW]` header and writes LF | reads `labview.conf`; a venv ini starts from `[LabVIEW]` when there is none | +| VI Server default | on | **off**: without `server.tcp.enabled=True` LabVIEW never listens. NI's image seeds all three `.conf` files with it | absent means off, and the error names the file, instead of a 120 s wait | +| VI Server port | 3363, 3364 on 2026 | 3363 on 2026 Q3 | read from the `.conf` | +| Protocol | | unchanged: handshake, error 63 until initialised, open, run, save, control values | | +| Paths on the wire (`PTH0`) | drive letter first | no drive and no root component: `/usr/local` is `usr`, `local` | decoded to `/usr/local/...`, not `usr\local\...` | +| Relink folder | `C:\...` | `/...`. Handed the backslashed form, the relink VI walks nothing and reports success: 0 items where the same folder gives 2 | sent as it is | +| Palette and menu refresh | two shipping VIs | the same VIs at the same relative paths | joined a component at a time (a `\` inside one `join` separates only on Windows) | +| `` tokens | environment variables | what `Get System Directory.vi` answers (below) | the same table | +| Records of a global install | `%ProgramData%\lvpm` | `/var/lib/lvpm//` | (was a `C:\ProgramData` directory under the working directory) | +| Index cache | `%LOCALAPPDATA%\lvpm\cache` | `$XDG_CACHE_HOME/lvpm`, else `~/.cache/lvpm` | | +| Relink VI, staged PostUninstall | `%TEMP%` | `/tmp` is shared, and a name another user wrote first is one this user cannot write | the per-user cache | +| Target Dir tails | either separator | `\addons\Foo` would be one directory named `addons\Foo` | split on both | +| Headless | `LV_RTE_HEADLESS=1` (2026 and later) | the same switch, also from 2026. LabVIEW starts its own `Xvfb :99` and needs no `DISPLAY`; the log is `/tmp/labview__headless__cur.txt` | the hint names it | +| `-pref ` | yes | yes: a second instance on its own ini and port runs beside the first and shares its Xvfb. The Linux binary has no `AllowMultipleInstances` key | unchanged | +| LVAddons | `C:\Program Files\NI\LVAddons` | `/usr/local/natinst/share/lvaddons` (NI's VI Analyzer support ships there). `LVAddons.AdditionalLocations` is honoured | the venv path goes with forward slashes | +| Restarting on a port | immediate | **a LabVIEW started while its port holds a TIME_WAIT connection (up to 60 s after the last one closed) never serves VI Server**, and never retries | waits for the port, 65 s at most, before starting LabVIEW | +| Ctrl+C | IDE detached | a child in lvpm's process group gets the terminal's SIGINT | LabVIEW gets a process group of its own | + +### `` tokens + +The tokens are the directory types of LabVIEW's `Get System Directory.vi` by +name. lvpm resolves them to what that VI returned over VI Server on Linux, run +as root: + +| Token | Linux | +|---|---| +| `` | `$HOME/Documents` | +| `` | `$HOME/Desktop` | +| `` | `$HOME` | +| ``, ``, `` | `/usr/local` | +| `` | `/usr/lib` | +| `` | `/` | +| `` | `/tmp` | + +The VI's remaining types, for reference: User Preferences `$HOME`, Public +Preferences `/etc`, System Installed Libraries `/usr/local/lib`, Public Cache +`/var/cache`. + +### LabVIEWCLI + +LabVIEWCLI on Linux wants `-LabVIEWPath`, the operation name first and +`-Headless` last; given `-Headless` in between, it prints its usage and exits +0. A clean mass compile says `MassCompile operation succeeded` and exits 0; a +broken VI shows as `### Bad VI:` in the log, with exit code 3. + +## Verified + +With `scripts/linux-e2e.sh`, all in one container: + +- `lvpm targets` reports `LabVIEW 2026 (64-bit) v26.3`. +- A headless `lvpm install --relink --hooks` in a project with no venv starts + LabVIEW, runs PreInstall, copies into `vi.lib` and `$HOME/Documents`, + relinks and runs PostInstall. Records land in `/var/lib/lvpm`. +- `list`, `refresh` (both VIs run), `relink --all`, `run-hooks` and + `uninstall --all` work, and the uninstall prunes what it created. +- `vi-save` rewrites a LabVIEW 2020 VI as 26.3. A caller whose subVI moved is + found, relinked and saved with a `` link. +- In a venv: `venv create`, then `install --relink` in a LabVIEW started with + `-pref` on the venv's port, then `launch` straight after. The overlay works: + a VI linked to `/lvpm_linux_test/...` runs on the venv's LabVIEW and + is broken (error 1003) on a fresh plain one. + +By hand: as a non-root user with no `.conf`, `lvpm start` stops at once with +the "VI Server is disabled" message, and the whole venv flow works without +root. + +## CI + +[.github/workflows/build.yml](../.github/workflows/build.yml), on every push +to `main`, every pull request, and by hand: + +- **build** — one job per platform on its own runner: `windows-latest` + (`x86_64-pc-windows-msvc`) and `ubuntu-latest` (static + `x86_64-unknown-linux-musl`). Each runs `cargo test` and a release build, + and uploads its executable. +- **collect** — puts both executables in one artifact, + `lvpm--`, for handing to testers. +- **linux-e2e** — `scripts/linux-e2e.sh` with the Linux executable, in + `nationalinstruments/labview:2026q3-linux` (pinned). + +## Not done + +What Linux still needs, most important first. + +1. **OS gates on packages.** lvpm ignores `Exclusive_OS`, both in the index + (`Platform.Exclusive_OS`) and per file group, so on Linux it would install + a package that declares itself Windows-only. LabVIEW 2026 for Linux ships + NI's Wine layer (`ni-wine`, `ni-wine-dotnet-runtime-80`, + `ni-dotnetcore-interface`); what that means for .NET nodes is not measured. +2. **Root.** The installation is owned by root. A global install needs write + access to it, and the relink and hooks run in a LabVIEW that has to save + into it. Under `sudo`, Ubuntu sets `$HOME` to root's, so lvpm reads root's + `labview.conf` and starts LabVIEW as root. A venv needs neither. lvpm should + check write access up front and say so. +3. **Case.** Linux is case-sensitive; much of what lvpm and packages were + written against is not. `install.rs` looks a manifest up by the name as + typed, so `uninstall OGLib_Error` misses. Archive entries are matched + case-insensitively but written in the spec's case. `venv::port_for` + lowercases the path, so two repos that differ only in case share a port. + And a VI that links a subVI in a different case does not load on Linux. +4. **Releases.** CI builds both executables; a tagged release still carries + only Windows, and there is no `install.sh` to match `install.ps1`. +5. **Not measured.** LabVIEW for Linux before 2026 (the `.conf` directory + name, `readme/UNINSTALL`, `LVAddons.AdditionalLocations` from 2024 Q1 on); + other distributions (RHEL, openSUSE); Community edition; an interactive IDE + on a real display. Headless mode arrived with LabVIEW 2026: the 2025 Q3 + binary has no `LV_RTE_HEADLESS` and no `--headless` (its image sets + `EnableCICDFeaturesForLabVIEW` instead), so lvpm cannot tell that a 2025 + machine is headless. +6. **Containers.** Start the container with `--init`. A LabVIEW that lvpm + started is reparented to PID 1, and `sleep infinity` never reaps it. The + TIME_WAIT rule applies to anything that restarts LabVIEW on a port within a + minute, LabVIEWCLI included. diff --git a/docs/roadmap.md b/docs/roadmap.md index 996ebc0..b6c652f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -211,4 +211,8 @@ workflow is the work. - `Replace Mode = If Newer` compares timestamps instead of "write when absent". - Read hook VIs' `error out` back. - A failure part-way through unpacking leaves files behind with no manifest. -- Linux target detection exists but is untested. +- Linux: install, relink, hooks, refresh and venvs verified against LabVIEW + 2026 Q3 in NI's container ([linux.md](linux.md)), and run in CI + (`.github/workflows/build.yml`). Still open there: `Exclusive_OS` gates, + root ownership of the install tree, case sensitivity, a Linux release + artifact. diff --git a/docs/vi-server-protocol.md b/docs/vi-server-protocol.md index ee71e47..755d19b 100644 --- a/docs/vi-server-protocol.md +++ b/docs/vi-server-protocol.md @@ -146,7 +146,13 @@ encoder serves both the wire and any offline path rewriting. An absolute Windows path contributes its drive letter as the first component: `C:\Git\lvpm\tools\X.vi` → `01 "C" 03 "Git" 04 "lvpm" 05 "tools" 06 "X.vi"`. -The length field covers the count field plus the components. +The length field covers the count field plus the components. The count field +is really a `u16` kind (0 absolute, 1 relative, 2 not a path) and a `u16` +count; an absolute path's kind is 0, so the two read the same. + +On Linux there is no drive and no root component: LabVIEW 2026 Q3 for Linux +sends and takes `/usr/local/x.vi` as `03 "usr" 05 "local" 04 "x.vi"`, kind 0. +Decoded, such a record has to be rooted again, or it reads as a relative path. ## `GetVIRef` (3) From a3d71030d411794c2adcaa179253c967665352a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 06:27:45 +0000 Subject: [PATCH 05/12] ci(linux): public packages on LabVIEW for Linux scripts/linux-packages.sh installs eleven OpenG libraries and the JKI State Machine from the public indexes into NI's Linux container: a headless install that must start no LabVIEW, a LabVIEWCLI mass compile of every folder they own that must report no bad VI, relink --all, an uninstall that must leave the LabVIEW tree as it was, and a venv relink. The linux-packages job runs it with the Linux executable from the build job and keeps the logs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JrYr5p1UVnscR92E5K9ZKf --- .github/workflows/build.yml | 24 +++++++ README.md | 1 + docs/linux.md | 7 ++ scripts/linux-packages.sh | 136 ++++++++++++++++++++++++++++++++++++ 4 files changed, 168 insertions(+) create mode 100755 scripts/linux-packages.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 947ba35..9c9516c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -131,3 +131,27 @@ jobs: # Pinned, so a new image is a change someone makes on purpose. - name: lvpm against LabVIEW 2026 for Linux run: LVPM=dist/lvpm scripts/linux-e2e.sh nationalinstruments/labview:2026q3-linux + + linux-packages: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download the Linux executable + uses: actions/download-artifact@v4 + with: + name: lvpm-linux-x64 + path: dist + + - name: Public packages on LabVIEW 2026 for Linux + run: LVPM=dist/lvpm OUT=out scripts/linux-packages.sh nationalinstruments/labview:2026q3-linux + + - name: Upload logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: linux-packages-logs + path: out diff --git a/README.md b/README.md index 1c54639..6ad087d 100644 --- a/README.md +++ b/README.md @@ -436,6 +436,7 @@ fetches them again. | `src/lv-src/relink-package.vi` | The relink VI embedded into the executable at build time (LabVIEW 2020) | | `scripts/install.ps1` | Download, verify and install the latest release | | `scripts/linux-e2e.sh` | End-to-end check against LabVIEW for Linux in NI's container image | +| `scripts/linux-packages.sh` | Public packages on LabVIEW for Linux: install, mass compile, relink, uninstall, venv | ## License diff --git a/docs/linux.md b/docs/linux.md index 64596cf..ada3354 100644 --- a/docs/linux.md +++ b/docs/linux.md @@ -119,6 +119,13 @@ to `main`, every pull request, and by hand: `lvpm--`, for handing to testers. - **linux-e2e** — `scripts/linux-e2e.sh` with the Linux executable, in `nationalinstruments/labview:2026q3-linux` (pinned). +- **linux-packages** — `scripts/linux-packages.sh` in the same image, with + public packages that are plain G and need no NI driver (eleven OpenG + libraries and the JKI State Machine, with their dependencies): a headless + install that must start no LabVIEW, a LabVIEWCLI mass compile of every + folder they own that must report no bad VI, `relink --all`, an uninstall + that must leave the LabVIEW tree as it was, and the same set relinked in a + venv. Its logs are uploaded. ## Not done diff --git a/scripts/linux-packages.sh b/scripts/linux-packages.sh new file mode 100755 index 0000000..2bc81b7 --- /dev/null +++ b/scripts/linux-packages.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# Real packages on LabVIEW for Linux, in NI's container image. +# +# Resolves a set of public packages that are plain G and need no NI driver +# (OpenG and the JKI State Machine), and then, in a throwaway container: +# 1. a headless `lvpm install`, which must not start LabVIEW; +# 2. a LabVIEWCLI mass compile of every folder the packages own, which must +# report no bad VI; +# 3. `lvpm relink --all` over the same folders; +# 4. `lvpm uninstall --all`, which must leave the LabVIEW tree as it found it; +# 5. the same set into a venv with `--relink`, relinked in a LabVIEW started +# on the venv. +# +# scripts/linux-packages.sh [image] default: nationalinstruments/labview:latest-linux +# +# Environment: +# LVPM a static lvpm to use instead of building one (see linux-e2e.sh) +# PACKAGES space-separated package names, instead of the list below +# SOURCE a repository (index URL or folder) to use instead of the public indexes +# OUT where the logs go (default: a temporary directory, removed afterwards) +# DOCKER_ARGS extra `docker run` arguments, e.g. a proxy +# +# Needs Docker and network access to the package sources. +set -euo pipefail + +IMAGE=${1:-nationalinstruments/labview:latest-linux} +ROOT=$(cd "$(dirname "$0")/.." && pwd) +PACKAGES=${PACKAGES:-"oglib_array oglib_boolean oglib_dictionary oglib_error oglib_file \ +oglib_lvdata oglib_md5 oglib_numeric oglib_string oglib_time oglib_variantconfig \ +jki_lib_state_machine"} +WORK=$(mktemp -d) +NAME=lvpm-packages-$$ +KEEP_OUT=${OUT:+yes} +OUT=${OUT:-$WORK/out} +mkdir -p "$OUT" +OUT=$(cd "$OUT" && pwd) +trap 'docker rm -f "$NAME" >/dev/null 2>&1 || true; rm -rf "$WORK"' EXIT + +if [ -n "${LVPM:-}" ]; then + cp "$LVPM" "$WORK/lvpm" +else + CC_x86_64_unknown_linux_musl=musl-gcc \ + cargo build --release --target x86_64-unknown-linux-musl --manifest-path "$ROOT/Cargo.toml" + cp "$ROOT/target/x86_64-unknown-linux-musl/release/lvpm" "$WORK/lvpm" +fi +chmod +x "$WORK/lvpm" + +sources="" +case "${SOURCE:-}" in + "") ;; + http://* | https://*) sources=$'[sources]\nsource = "'"$SOURCE"$'"\ndefaults = false\n' ;; + *) + cp -r "$SOURCE" "$WORK/source" + sources=$'[sources]\nsource = "../source"\ndefaults = false\n' + ;; +esac +for d in global venv; do + mkdir "$WORK/$d" + { + printf '[project]\nname = "linux-packages-%s"\nlabview = "2026"\n\n%s\n[dependencies]\n' "$d" "$sources" + for p in $PACKAGES; do printf '%s = "*"\n' "$p"; done + } > "$WORK/$d/lvpm.toml" +done + +# shellcheck disable=SC2086 # DOCKER_ARGS is a list of arguments +docker run -d --init --name "$NAME" -e LV_RTE_HEADLESS=1 ${DOCKER_ARGS:-} \ + -v "$WORK:/work" -v "$OUT:/out" "$IMAGE" sleep infinity >/dev/null +status=0 +docker exec -i "$NAME" bash -s <<'SH' || status=$? +set -u +L=/work/lvpm +LV=$($L targets | awk '{print $NF; exit}') +# LabVIEWCLI wants the operation first and -Headless last: in between, the flag +# takes the next argument with it, and the CLI prints its usage and exits 0. +cli() { LabVIEWCLI -OperationName "$1" -LabVIEWPath "$LV/labview" "${@:2}" -Headless; } +fails=0 +check() { if eval "$2"; then echo "ok $1"; else echo "FAIL $1"; fails=$((fails + 1)); fi; } +tree() { find "$LV" -path "$LV/VIObjCache" -prune -o -print | sort; } + +tree > /out/tree-before.txt +cd /work/global +check "headless install resolves and copies every package" '$L install > /out/install.log 2>&1' +cat /out/install.log +check "a headless install starts no LabVIEW" '! pgrep -x labview >/dev/null' +$L list > /out/list.txt 2>/dev/null; cat /out/list.txt +asked=$(sed -n '/^\[dependencies\]/,$p' lvpm.toml | grep -oE '^[A-Za-z0-9_.-]+' || true) +missing=$(for p in $asked; do grep -q "^$p " /out/list.txt || echo "$p"; done) +check "lvpm list shows every package asked for" '[ -n "$asked" ] && [ -z "$missing" ]' +[ -n "$missing" ] && echo " missing: $missing" + +# Every folder the packages own, nested ones folded into their parent: the +# same set the relink pass walks. +python3 - > /work/folders.txt <<'PY' +import glob, json +dirs = sorted({d for f in glob.glob("/var/lib/lvpm/*/installed/*.json") for d in json.load(open(f))["relink_folders"]}) +print("\n".join(d for d in dirs if not any(d.startswith(p + "/") for p in dirs))) +PY +echo "mass compiling $(wc -l < /work/folders.txt) folder(s)" +n=0; mc_ok=yes +while read -r d; do + n=$((n + 1)); log=/out/masscompile-$n.log + cli MassCompile -DirectoryToCompile "$d" -MassCompileLogFile "$log" > "/out/masscompile-$n.out" 2>&1 + if grep -q "MassCompile operation succeeded" "/out/masscompile-$n.out" && [ -f "$log" ] && ! grep -q "Bad VI" "$log"; then + echo " $d ... ok" + else + mc_ok=no + echo " $d ... FAILED" + { [ -f "$log" ] && grep -E "Bad VI|Search failed" "$log" || tail -5 "/out/masscompile-$n.out"; } | + sort -u | head -12 | sed 's/^ */ /' + fi +done < /work/folders.txt +check "mass compile reports no bad VI" '[ $mc_ok = yes ] && [ $n -gt 0 ]' + +check "relink --all over the installed packages" '$L relink --all > /out/relink.log 2>&1' +grep -E "^\s+/|FAILED|saved" /out/relink.log | head -40 +check "lvpm list shows every package relinked" '! $L list 2>/dev/null | grep -q "NOT relinked"' +cli CloseLabVIEW > /out/close.log 2>&1 || true +for _ in $(seq 30); do pgrep -x labview >/dev/null || break; sleep 1; done + +check "uninstall --all" '$L uninstall --all > /out/uninstall.log 2>&1' +tree > /out/tree-after.txt +check "the LabVIEW tree is as it was before the install" 'diff /out/tree-before.txt /out/tree-after.txt > /out/tree.diff' +[ -s /out/tree.diff ] && head -20 /out/tree.diff + +cd /work/venv +check "venv create" '$L venv create > /out/venv.log 2>&1' +check "venv install --relink" '$L install --relink > /out/venv-install.log 2>&1' +grep -E "^\+|FAILED|relinking|skipped" /out/venv-install.log | head -40 +check "every venv package relinked" \ + '$L list 2>/dev/null | grep -q " relinked" && ! $L list 2>/dev/null | grep -q "NOT relinked"' + +echo +[ "$fails" = 0 ] && echo "all checks passed" || { echo "$fails check(s) failed; logs in the OUT directory"; exit 1; } +SH +[ -n "$KEEP_OUT" ] && echo "logs: $OUT" +exit "$status" From 6bc0f0d3f736cf0251859546afc6edfbdfdc9699 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 06:56:42 +0000 Subject: [PATCH 06/12] docs(roadmap): macOS support What carries over from the Linux port, what needs a macOS branch and a Mac to measure it on, and why CI cannot cover it yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JrYr5p1UVnscR92E5K9ZKf --- docs/roadmap.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/roadmap.md b/docs/roadmap.md index b6c652f..de77238 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -206,6 +206,28 @@ workflow is the work. lvpm's own tooling on packages, which is the reading of "modifying LabVIEW code" to settle before that step runs on shared infrastructure. +## macOS + +Not supported. The Linux code is `cfg(not(windows))`, so a macOS build +compiles and gets every LabVIEW-specific answer wrong; until there is a +macOS branch, those items should be `cfg(target_os = "linux")` and macOS a +`compile_error!`. + +- **What carries over from Linux:** forward slashes, LF ini files, the + per-user cache, LabVIEW in its own process group, the VI Server client, + venvs. +- **What needs its own branch, each to be measured on a Mac with LabVIEW:** + the install location (likely `/Applications/National Instruments/LabVIEW + 64-bit/`), the executable inside the `.app` bundle, the preferences + file (likely under `~/Library/Preferences/`), the internal version (likely + the bundle's `Info.plist`), the `` tokens (run `Get System + Directory.vi`, as for Linux), `PTH0` paths (the first component may be the + volume name), where install records go, and whether a TIME_WAIT port blocks + VI Server as it does on Linux. +- **The obstacle is testing:** NI publishes no macOS container, so there is + no equivalent of `scripts/linux-e2e.sh` in CI without a macOS runner that + has a licensed LabVIEW. + ## Housekeeping - `Replace Mode = If Newer` compares timestamps instead of "write when absent". From 386cc22abbcdde001f7099be498c07c4b8d7bf5b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 07:05:05 +0000 Subject: [PATCH 07/12] refactor: gate Linux facts on target_os = "linux" What holds only for LabVIEW on Linux (install location, executable, preferences file, version file, roots, /var/lib records, rooted PTH0 paths, the missing-ini default) is now cfg(target_os = "linux") instead of cfg(not(windows)). Separators, line ends and the per-user cache stay not(windows): they hold on any POSIX system. Any other platform stops with a compile_error!, so macOS cannot build and then look in the wrong places. The Linux build is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JrYr5p1UVnscR92E5K9ZKf --- docs/linux.md | 7 +++++-- docs/roadmap.md | 8 ++++---- src/launch.rs | 2 +- src/main.rs | 6 ++++++ src/target.rs | 16 ++++++++-------- src/viserver.rs | 4 ++-- 6 files changed, 26 insertions(+), 17 deletions(-) diff --git a/docs/linux.md b/docs/linux.md index ada3354..d7e5bb8 100644 --- a/docs/linux.md +++ b/docs/linux.md @@ -25,8 +25,11 @@ one to ship. ### Windows stays as it was The platform is chosen when lvpm is compiled, not when it runs: each -difference below is a `#[cfg(windows)]` / `#[cfg(not(windows))]` pair, and -the Windows half is the code that was there before. What a Windows user can +difference below is a `#[cfg(windows)]` / `#[cfg(target_os = "linux")]` +pair, or `#[cfg(not(windows))]` where the Linux half holds on any POSIX +system (separators, line ends, the per-user cache), and the Windows half is +the code that was there before. Other platforms do not build (see the +roadmap's macOS section). What a Windows user can see changed is small: the "VI Server is disabled" and "no LabVIEW.exe" messages name the full path of the file, and a `Target Dir` such as `/addons/Foo` now expands to `...\vi.lib\addons\Foo` instead of diff --git a/docs/roadmap.md b/docs/roadmap.md index de77238..1f7e223 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -208,10 +208,10 @@ workflow is the work. ## macOS -Not supported. The Linux code is `cfg(not(windows))`, so a macOS build -compiles and gets every LabVIEW-specific answer wrong; until there is a -macOS branch, those items should be `cfg(target_os = "linux")` and macOS a -`compile_error!`. +Not supported, and a macOS build stops with a `compile_error!` in `main.rs`. +What is a fact about LabVIEW on Linux is `cfg(target_os = "linux")`; what is +true of any POSIX system is `cfg(not(windows))` or `cfg(unix)` and needs no +new branch. - **What carries over from Linux:** forward slashes, LF ini files, the per-user cache, LabVIEW in its own process group, the VI Server client, diff --git a/src/launch.rs b/src/launch.rs index e14b97f..23519f8 100644 --- a/src/launch.rs +++ b/src/launch.rs @@ -78,7 +78,7 @@ pub fn write_ini(v: &Venv) -> Result { Ok(text) => text, // On Linux the file is per user and only exists once this user has // started LabVIEW; until then the defaults are all there is to inherit. - Err(e) if e.kind() == std::io::ErrorKind::NotFound && cfg!(not(windows)) => { + Err(e) if e.kind() == std::io::ErrorKind::NotFound && cfg!(target_os = "linux") => { format!("[LabVIEW]{EOL}") } Err(e) => return Err(e).with_context(|| format!("reading {}", src.display())), diff --git a/src/main.rs b/src/main.rs index 0e326e2..dc22f1c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,11 @@ //! lvpm — an open-source package manager for LabVIEW packages. +// lvpm knows where LabVIEW keeps its files on Windows and on Linux. Every +// other platform would build and then look in the wrong places, so it does not +// build: macOS needs a branch of its own (docs/roadmap.md). +#[cfg(not(any(windows, target_os = "linux")))] +compile_error!("lvpm supports Windows and Linux; macOS is on the roadmap (docs/roadmap.md)"); + mod index; mod install; mod launch; diff --git a/src/target.rs b/src/target.rs index aa3b7a4..e60bf25 100644 --- a/src/target.rs +++ b/src/target.rs @@ -50,7 +50,7 @@ impl LvTarget { { self.path.join("LabVIEW.ini") } - #[cfg(not(windows))] + #[cfg(target_os = "linux")] { env_path("HOME", "/root") .join("natinst/.config") @@ -62,7 +62,7 @@ impl LvTarget { #[cfg(windows)] const EXE: &str = "LabVIEW.exe"; -#[cfg(not(windows))] +#[cfg(target_os = "linux")] const EXE: &str = "labview"; #[cfg(windows)] @@ -104,7 +104,7 @@ pub fn detect() -> Result> { Ok(out) } -#[cfg(not(windows))] +#[cfg(target_os = "linux")] pub fn detect() -> Result> { // LabVIEW on Linux lives under /usr/local/natinst/LabVIEW--64 unless // its prefix was moved at install time; either way its package links @@ -138,7 +138,7 @@ pub fn detect() -> Result> { /// does not carry: a 2026 Q3 is 26.3, and that is what a package's /// `Exclusive_LabVIEW_Version` gate is compared against. NI's uninstall /// script in the install dir states it as `LV_MAJOR_VER=26` / `LV_MINOR_VER=3`. -#[cfg(not(windows))] +#[cfg(target_os = "linux")] fn linux_version(install: &Path) -> Option { let text = std::fs::read_to_string(install.join("readme/UNINSTALL")).ok()?; let var = |key: &str| { @@ -257,7 +257,7 @@ impl Roots { /// The machine roots are what LabVIEW's own `Get System Directory.vi` /// answers on Linux (2026 Q3, read over VI Server): the `` tokens /// are that VI's directory types by name. - #[cfg(not(windows))] + #[cfg(target_os = "linux")] pub fn labview(t: &LvTarget) -> Roots { let home = env_path("HOME", "/root"); Roots { @@ -360,7 +360,7 @@ impl Roots { pub fn store_dir(&self) -> PathBuf { #[cfg(windows)] let machine = env_path("ProgramData", "C:\\ProgramData"); - #[cfg(not(windows))] + #[cfg(target_os = "linux")] let machine = PathBuf::from("/var/lib"); match (&self.venv, &self.scratch, &self.target) { (Some(v), _, _) => v.join(".lvpm").join("installed"), @@ -504,7 +504,7 @@ mod tests { /// Directory.vi says, and a global install's manifests in /var/lib — not /// in a `C:\ProgramData` relative to the working directory, which is what /// the Windows fallbacks amount to there. - #[cfg(not(windows))] + #[cfg(target_os = "linux")] #[test] fn linux_roots_are_the_ones_labview_reports() { let t = LvTarget { version: 26.3, bitness: 64, path: PathBuf::from(LV) }; @@ -520,7 +520,7 @@ mod tests { assert_eq!(t.ini(), home.join("natinst/.config/LabVIEW-2026/labview.conf")); } - #[cfg(not(windows))] + #[cfg(target_os = "linux")] #[test] fn linux_version_comes_from_the_uninstall_script() { let dir = std::env::temp_dir().join(format!("lvpm-target-test-{}", std::process::id())); diff --git a/src/viserver.rs b/src/viserver.rs index 69b8175..daf7608 100644 --- a/src/viserver.rs +++ b/src/viserver.rs @@ -971,7 +971,7 @@ impl<'a> Reader<'a> { } // One root and no drives: "/usr/local" comes back as "usr", "local" // and must be rooted again, or it reads as a relative path. - #[cfg(not(windows))] + #[cfg(target_os = "linux")] { Ok(match kind { 0 => format!("/{}", parts.join("/")), @@ -1635,7 +1635,7 @@ mod tests { /// What LabVIEW 2026 Q3 on Linux answered for `Application Directory.vi`: /// the same record as on Windows, minus a drive. Read back as `usr\local\...` /// it would name a relative path under the working directory. - #[cfg(not(windows))] + #[cfg(target_os = "linux")] #[test] fn decodes_a_linux_pth0_record_as_a_rooted_path() { let rec = b"PTH0\x00\x00\x00\x13\x00\x00\x00\x03\x03usr\x05local\x04x.vi"; From 76ed3636510cff1d674b2b95d394247e75e56065 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 07:06:27 +0000 Subject: [PATCH 08/12] ci: run on every branch, not only main Pushes to any branch now build, test and publish the executables, so a feature branch is integration-tested before it is merged; tags stay out. A pull request from a branch of this repository is skipped, since the push that updated the branch already ran; one from a fork still runs. A newer push cancels a branch's run in progress, except on main. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JrYr5p1UVnscR92E5K9ZKf --- .github/workflows/build.yml | 14 +++++++++++++- docs/linux.md | 3 ++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9c9516c..884647b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,20 +2,32 @@ # and run lvpm against LabVIEW for Linux in NI's container (docs/linux.md). name: build +# Every branch, so a feature branch is tested before it is merged. Tags are +# left out. on: push: - branches: [main] + branches: ['**'] pull_request: workflow_dispatch: permissions: contents: read +# A newer push to a branch makes its running build moot; on main every commit +# keeps its run. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + env: CARGO_TERM_COLOR: always jobs: build: + # A pull request from a branch of this repository was built by the push + # that updated the branch; only one from a fork needs a run of its own. + # The jobs below need this one, so they are skipped with it. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository strategy: fail-fast: false matrix: diff --git a/docs/linux.md b/docs/linux.md index d7e5bb8..8f6e8e2 100644 --- a/docs/linux.md +++ b/docs/linux.md @@ -112,7 +112,8 @@ root. ## CI [.github/workflows/build.yml](../.github/workflows/build.yml), on every push -to `main`, every pull request, and by hand: +to any branch, on pull requests from forks, and by hand. A newer push to a +branch other than `main` cancels its run still in progress. - **build** — one job per platform on its own runner: `windows-latest` (`x86_64-pc-windows-msvc`) and `ubuntu-latest` (static From 08e3292a2e2fe5ce53918b324e464b2d5e38ca8f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 07:19:03 +0000 Subject: [PATCH 09/12] ci(linux): fixes from the first runs on GitHub - Hand the container's root-owned files back before cleanup: on a CI runner's user the final rm failed and failed both jobs, all checks passed or not. - Count as bad only the VIs the packages installed, each once: folders a package shares with LabVIEW hold LabVIEW's own files too. - A package with nothing to relink stays unmarked; the relink checks now read the manifests instead of `lvpm list`. - The uninstall check allows the .aliases, .lvlps and .UserState files LabVIEW writes beside a project it opens, and says how many. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JrYr5p1UVnscR92E5K9ZKf --- docs/linux.md | 10 ++-- scripts/linux-e2e.sh | 9 ++- scripts/linux-packages.sh | 113 ++++++++++++++++++++++++++++++-------- 3 files changed, 105 insertions(+), 27 deletions(-) diff --git a/docs/linux.md b/docs/linux.md index 8f6e8e2..04d7a88 100644 --- a/docs/linux.md +++ b/docs/linux.md @@ -126,10 +126,12 @@ branch other than `main` cancels its run still in progress. - **linux-packages** — `scripts/linux-packages.sh` in the same image, with public packages that are plain G and need no NI driver (eleven OpenG libraries and the JKI State Machine, with their dependencies): a headless - install that must start no LabVIEW, a LabVIEWCLI mass compile of every - folder they own that must report no bad VI, `relink --all`, an uninstall - that must leave the LabVIEW tree as it was, and the same set relinked in a - venv. Its logs are uploaded. + install that must start no LabVIEW; a LabVIEWCLI mass compile of every + folder they own, where none of their own files may be a bad VI (a folder + shared with LabVIEW holds LabVIEW's files too, and those do not count); + `relink --all`; an uninstall that must leave the LabVIEW tree as it was, + apart from project files LabVIEW writes itself; and the same set relinked + in a venv. Its logs are uploaded. ## Not done diff --git a/scripts/linux-e2e.sh b/scripts/linux-e2e.sh index 9e6476a..da97158 100755 --- a/scripts/linux-e2e.sh +++ b/scripts/linux-e2e.sh @@ -19,7 +19,14 @@ IMAGE=${1:-nationalinstruments/labview:latest-linux} ROOT=$(cd "$(dirname "$0")/.." && pwd) WORK=$(mktemp -d) NAME=lvpm-e2e-$$ -trap 'docker rm -f "$NAME" >/dev/null 2>&1 || true; rm -rf "$WORK"' EXIT +# The container writes into $WORK as root; it hands it back before it goes, or +# a CI runner's user could not remove it. +cleanup() { + docker exec "$NAME" chown -R "$(id -u):$(id -g)" /work >/dev/null 2>&1 || true + docker rm -f "$NAME" >/dev/null 2>&1 || true + rm -rf "$WORK" || true +} +trap cleanup EXIT # NI's image is Ubuntu 22.04 (glibc 2.35); a static binary runs there whatever # the build host's glibc. diff --git a/scripts/linux-packages.sh b/scripts/linux-packages.sh index 2bc81b7..079e468 100755 --- a/scripts/linux-packages.sh +++ b/scripts/linux-packages.sh @@ -34,7 +34,14 @@ KEEP_OUT=${OUT:+yes} OUT=${OUT:-$WORK/out} mkdir -p "$OUT" OUT=$(cd "$OUT" && pwd) -trap 'docker rm -f "$NAME" >/dev/null 2>&1 || true; rm -rf "$WORK"' EXIT +# The container writes into the mounted directories as root; it hands them back +# before it goes, or a CI runner's user could not remove them. +cleanup() { + docker exec "$NAME" chown -R "$(id -u):$(id -g)" /work /out >/dev/null 2>&1 || true + docker rm -f "$NAME" >/dev/null 2>&1 || true + rm -rf "$WORK" || true +} +trap cleanup EXIT if [ -n "${LVPM:-}" ]; then cp "$LVPM" "$WORK/lvpm" @@ -76,6 +83,53 @@ cli() { LabVIEWCLI -OperationName "$1" -LabVIEWPath "$LV/labview" "${@:2}" -Head fails=0 check() { if eval "$2"; then echo "ok $1"; else echo "FAIL $1"; fails=$((fails + 1)); fi; } tree() { find "$LV" -path "$LV/VIObjCache" -prune -o -print | sort; } +# Packages with something to relink that are not marked relinked. One that +# installed no VI, library or LLB has nothing to relink and stays unmarked. +unrelinked() { + python3 - "$1" <<'PY' +import glob, json, sys +for f in sorted(glob.glob(sys.argv[1] + "/*.json")): + m = json.load(open(f)) + if m["relink_folders"] and not m["relinked"]: + print(m["name"]) +PY +} +# The bad VIs in a mass-compile log that a package installed, each once (the +# log names a VI again for every caller that loads it); a VI inside an LLB +# counts for the LLB. Folders a package shares with LabVIEW hold LabVIEW's own +# files too, and those are not what this checks. +ours() { + python3 - "$1" <<'PY' +import re, sys +installed = set(open("/work/installed.txt").read().split("\n")) - {""} +seen = [] +for path in re.findall(r'Path="([^"]*)"', open(sys.argv[1], errors="replace").read()): + p = path + while p not in installed and "/" in p: + p = p.rsplit("/", 1)[0] + if p in installed and path not in seen: + seen.append(path) +print("\n".join(seen)) +PY +} +# What uninstall left behind or took away. LabVIEW writes .aliases, .lvlps and +# .UserState files beside a project or library it opens; those are its own. +leftovers() { + python3 - <<'PY' +import os +before = set(open("/out/tree-before.txt").read().split("\n")) +after = set(open("/out/tree-after.txt").read().split("\n")) +side = lambda p: p.endswith((".aliases", ".lvlps")) or ".UserState" in p +def only_side(d): + files = [os.path.join(r, f) for r, _, fs in os.walk(d) for f in fs] + return files and all(side(f) for f in files) +for p in sorted(after - before): + if not side(p) and not (os.path.isdir(p) and only_side(p)): + print("left behind", p) +for p in sorted(before - after - {""}): + print("taken away ", p) +PY +} tree > /out/tree-before.txt cd /work/global @@ -88,46 +142,61 @@ missing=$(for p in $asked; do grep -q "^$p " /out/list.txt || echo "$p"; done) check "lvpm list shows every package asked for" '[ -n "$asked" ] && [ -z "$missing" ]' [ -n "$missing" ] && echo " missing: $missing" -# Every folder the packages own, nested ones folded into their parent: the -# same set the relink pass walks. -python3 - > /work/folders.txt <<'PY' +# Every folder the packages own, nested ones folded into their parent (the set +# the relink pass walks), and every file they installed. +python3 - <<'PY' import glob, json -dirs = sorted({d for f in glob.glob("/var/lib/lvpm/*/installed/*.json") for d in json.load(open(f))["relink_folders"]}) -print("\n".join(d for d in dirs if not any(d.startswith(p + "/") for p in dirs))) +ms = [json.load(open(f)) for f in glob.glob("/var/lib/lvpm/*/installed/*.json")] +dirs = sorted({d for m in ms for d in m["relink_folders"]}) +open("/work/folders.txt", "w").write("".join(d + "\n" for d in dirs if not any(d.startswith(p + "/") for p in dirs))) +open("/work/installed.txt", "w").write("".join(f + "\n" for m in ms for f in m["files"])) PY echo "mass compiling $(wc -l < /work/folders.txt) folder(s)" -n=0; mc_ok=yes +n=0; ran=yes; bad_total=0 while read -r d; do - n=$((n + 1)); log=/out/masscompile-$n.log - cli MassCompile -DirectoryToCompile "$d" -MassCompileLogFile "$log" > "/out/masscompile-$n.out" 2>&1 - if grep -q "MassCompile operation succeeded" "/out/masscompile-$n.out" && [ -f "$log" ] && ! grep -q "Bad VI" "$log"; then - echo " $d ... ok" + n=$((n + 1)); log=/out/masscompile-$n.log; out=/out/masscompile-$n.out + cli MassCompile -DirectoryToCompile "$d" -MassCompileLogFile "$log" > "$out" 2>&1 + if ! grep -q "MassCompile operation" "$out" || [ ! -f "$log" ]; then + ran=no + echo " $d ... LabVIEWCLI did not compile it" + tail -5 "$out" | sed 's/^/ /' + continue + fi + bad=$(ours "$log") + count=$(printf '%s' "$bad" | grep -c . || true) + others=$(( $(grep -c "Bad VI" "$log" || true) - count )) + note=""; [ "$others" -gt 0 ] && note=" ($others bad VI(s) of LabVIEW's own in this folder, not counted)" + if [ "$count" = 0 ]; then + echo " $d ... ok$note" else - mc_ok=no - echo " $d ... FAILED" - { [ -f "$log" ] && grep -E "Bad VI|Search failed" "$log" || tail -5 "/out/masscompile-$n.out"; } | - sort -u | head -12 | sed 's/^ */ /' + bad_total=$((bad_total + count)) + echo " $d ... $count bad VI(s)$note" + printf '%s\n' "$bad" | head -10 | sed "s|^$LV/| |" + [ "$count" -gt 10 ] && echo " ... and $((count - 10)) more (masscompile-$n.log)" fi done < /work/folders.txt -check "mass compile reports no bad VI" '[ $mc_ok = yes ] && [ $n -gt 0 ]' +check "mass compile finds no bad VI among the packages' files" '[ $ran = yes ] && [ $n -gt 0 ] && [ $bad_total = 0 ]' check "relink --all over the installed packages" '$L relink --all > /out/relink.log 2>&1' -grep -E "^\s+/|FAILED|saved" /out/relink.log | head -40 -check "lvpm list shows every package relinked" '! $L list 2>/dev/null | grep -q "NOT relinked"' +grep -E "^\s+/|FAILED|saved|nothing to relink" /out/relink.log | head -40 +check "every package with something to relink is relinked" '[ -z "$(unrelinked "/var/lib/lvpm/*/installed")" ]' cli CloseLabVIEW > /out/close.log 2>&1 || true for _ in $(seq 30); do pgrep -x labview >/dev/null || break; sleep 1; done check "uninstall --all" '$L uninstall --all > /out/uninstall.log 2>&1' tree > /out/tree-after.txt -check "the LabVIEW tree is as it was before the install" 'diff /out/tree-before.txt /out/tree-after.txt > /out/tree.diff' -[ -s /out/tree.diff ] && head -20 /out/tree.diff +leftovers > /out/leftovers.txt +check "uninstall leaves the LabVIEW tree as it was" '[ ! -s /out/leftovers.txt ]' +head -20 /out/leftovers.txt +side=$(comm -13 /out/tree-before.txt /out/tree-after.txt | grep -cE '\.aliases$|\.lvlps$|\.UserState' || true) +[ "$side" -gt 0 ] && echo " note: $side project file(s) LabVIEW wrote itself are still there" cd /work/venv check "venv create" '$L venv create > /out/venv.log 2>&1' check "venv install --relink" '$L install --relink > /out/venv-install.log 2>&1' grep -E "^\+|FAILED|relinking|skipped" /out/venv-install.log | head -40 -check "every venv package relinked" \ - '$L list 2>/dev/null | grep -q " relinked" && ! $L list 2>/dev/null | grep -q "NOT relinked"' +check "every venv package with something to relink is relinked" \ + '[ -n "$(ls .project/.lvpm/installed)" ] && [ -z "$(unrelinked .project/.lvpm/installed)" ]' echo [ "$fails" = 0 ] && echo "all checks passed" || { echo "$fails check(s) failed; logs in the OUT directory"; exit 1; } From 79a2a9fbcf072493d6a03b1dbecd1661ca3265c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 07:48:34 +0000 Subject: [PATCH 10/12] ci: test with LUnit and G-Image, drop the bad-package workarounds - linux-packages installs astemes_lib_lunit and dataflow_g_lib_g_image with --hooks (G-Image needs its PostInstall), and checks that every hook ran. The checks are the plain ones again: any bad VI, any unrelinked package, any change to the LabVIEW tree fails. - Logs are written inside the work directory and copied to OUT, so the cleanup changes the owner of nothing but its own files. - linux-e2e: `grep -c relinked` also counted "NOT relinked" lines. - Pull requests run again: skipping those from this repository left a branch without the workflow with no CI at all. On main each commit has a concurrency group of its own, so no queued run is dropped. - The VI Server default and the headless log path are per-OS constants, so another platform fails to compile instead of taking Linux's values. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JrYr5p1UVnscR92E5K9ZKf --- .github/workflows/build.yml | 12 +-- docs/linux.md | 18 ++-- scripts/linux-e2e.sh | 2 +- scripts/linux-packages.sh | 165 ++++++++++++------------------------ src/launch.rs | 14 +-- src/viserver.rs | 14 ++- 6 files changed, 84 insertions(+), 141 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 884647b..8e07c4c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,21 +13,17 @@ on: permissions: contents: read -# A newer push to a branch makes its running build moot; on main every commit -# keeps its run. +# A newer push to a branch makes its running build moot. On main each commit +# gets a group of its own, so none is cancelled or dropped from the queue. concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.sha || github.ref }} + cancel-in-progress: true env: CARGO_TERM_COLOR: always jobs: build: - # A pull request from a branch of this repository was built by the push - # that updated the branch; only one from a fork needs a run of its own. - # The jobs below need this one, so they are skipped with it. - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository strategy: fail-fast: false matrix: diff --git a/docs/linux.md b/docs/linux.md index 04d7a88..8f53ba9 100644 --- a/docs/linux.md +++ b/docs/linux.md @@ -112,8 +112,9 @@ root. ## CI [.github/workflows/build.yml](../.github/workflows/build.yml), on every push -to any branch, on pull requests from forks, and by hand. A newer push to a -branch other than `main` cancels its run still in progress. +to any branch, on every pull request (which tests the merge with its base), +and by hand. A newer push to a branch other than `main` cancels its run still +in progress. - **build** — one job per platform on its own runner: `windows-latest` (`x86_64-pc-windows-msvc`) and `ubuntu-latest` (static @@ -124,14 +125,11 @@ branch other than `main` cancels its run still in progress. - **linux-e2e** — `scripts/linux-e2e.sh` with the Linux executable, in `nationalinstruments/labview:2026q3-linux` (pinned). - **linux-packages** — `scripts/linux-packages.sh` in the same image, with - public packages that are plain G and need no NI driver (eleven OpenG - libraries and the JKI State Machine, with their dependencies): a headless - install that must start no LabVIEW; a LabVIEWCLI mass compile of every - folder they own, where none of their own files may be a bad VI (a folder - shared with LabVIEW holds LabVIEW's files too, and those do not count); - `relink --all`; an uninstall that must leave the LabVIEW tree as it was, - apart from project files LabVIEW writes itself; and the same set relinked - in a venv. Its logs are uploaded. + LUnit and G-Image from the public indexes: a headless `install --hooks` + (G-Image needs its PostInstall) in which every hook must run, a LabVIEWCLI + mass compile of every folder they own that must report no bad VI, + `relink --all`, an uninstall that must leave the LabVIEW tree as it was, + and the same set relinked in a venv. Its logs are uploaded. ## Not done diff --git a/scripts/linux-e2e.sh b/scripts/linux-e2e.sh index da97158..9182569 100755 --- a/scripts/linux-e2e.sh +++ b/scripts/linux-e2e.sh @@ -138,7 +138,7 @@ check "both hooks ran" 'grep -q "pre-install ok" /work/log && grep -Eq "lvpm_lin check "install records kept in /var/lib/lvpm" '[ -f /var/lib/lvpm/LabVIEW-2026-64bit/installed/lvpm_linux_test.json ]' check "a backslashed Target Dir lands in real subfolders" \ 'run install lvpm_linux_relink_test --relink && [ -f "$LV/vi.lib/lvpm_linux_relink_test/sub/Relink Packages.vi" ]' -check "list shows both relinked" '[ "$($L list 2>/dev/null | grep -c relinked)" = 2 ]' +check "list shows both relinked" '[ "$($L list 2>/dev/null | grep -v "NOT relinked" | grep -c relinked)" = 2 ]' check "run-hooks reruns a PostInstall" 'run run-hooks lvpm_linux_test --labview-version 2026' VI=$(ls "$HOME"/.cache/lvpm/lvpm-relink-package-*.vi) diff --git a/scripts/linux-packages.sh b/scripts/linux-packages.sh index 079e468..8fcd920 100755 --- a/scripts/linux-packages.sh +++ b/scripts/linux-packages.sh @@ -1,15 +1,16 @@ #!/usr/bin/env bash # Real packages on LabVIEW for Linux, in NI's container image. # -# Resolves a set of public packages that are plain G and need no NI driver -# (OpenG and the JKI State Machine), and then, in a throwaway container: -# 1. a headless `lvpm install`, which must not start LabVIEW; +# Installs LUnit and G-Image from the public indexes and then, in a throwaway +# container: +# 1. a headless `lvpm install --hooks`: G-Image's PostInstall has to run for +# its VIs to work, and every hook must have run; # 2. a LabVIEWCLI mass compile of every folder the packages own, which must # report no bad VI; # 3. `lvpm relink --all` over the same folders; # 4. `lvpm uninstall --all`, which must leave the LabVIEW tree as it found it; # 5. the same set into a venv with `--relink`, relinked in a LabVIEW started -# on the venv. +# on the venv (a venv runs no hooks). # # scripts/linux-packages.sh [image] default: nationalinstruments/labview:latest-linux # @@ -25,19 +26,13 @@ set -euo pipefail IMAGE=${1:-nationalinstruments/labview:latest-linux} ROOT=$(cd "$(dirname "$0")/.." && pwd) -PACKAGES=${PACKAGES:-"oglib_array oglib_boolean oglib_dictionary oglib_error oglib_file \ -oglib_lvdata oglib_md5 oglib_numeric oglib_string oglib_time oglib_variantconfig \ -jki_lib_state_machine"} +PACKAGES=${PACKAGES:-"astemes_lib_lunit dataflow_g_lib_g_image"} WORK=$(mktemp -d) NAME=lvpm-packages-$$ -KEEP_OUT=${OUT:+yes} -OUT=${OUT:-$WORK/out} -mkdir -p "$OUT" -OUT=$(cd "$OUT" && pwd) -# The container writes into the mounted directories as root; it hands them back -# before it goes, or a CI runner's user could not remove them. +# The container writes into $WORK as root; it hands it back before it goes, or +# a CI runner's user could not remove it. cleanup() { - docker exec "$NAME" chown -R "$(id -u):$(id -g)" /work /out >/dev/null 2>&1 || true + docker exec "$NAME" chown -R "$(id -u):$(id -g)" /work >/dev/null 2>&1 || true docker rm -f "$NAME" >/dev/null 2>&1 || true rm -rf "$WORK" || true } @@ -68,14 +63,16 @@ for d in global venv; do for p in $PACKAGES; do printf '%s = "*"\n' "$p"; done } > "$WORK/$d/lvpm.toml" done +mkdir "$WORK/out" # shellcheck disable=SC2086 # DOCKER_ARGS is a list of arguments docker run -d --init --name "$NAME" -e LV_RTE_HEADLESS=1 ${DOCKER_ARGS:-} \ - -v "$WORK:/work" -v "$OUT:/out" "$IMAGE" sleep infinity >/dev/null + -v "$WORK:/work" "$IMAGE" sleep infinity >/dev/null status=0 docker exec -i "$NAME" bash -s <<'SH' || status=$? set -u L=/work/lvpm +O=/work/out LV=$($L targets | awk '{print $NF; exit}') # LabVIEWCLI wants the operation first and -Headless last: in between, the flag # takes the next argument with it, and the CLI prints its usage and exits 0. @@ -83,123 +80,67 @@ cli() { LabVIEWCLI -OperationName "$1" -LabVIEWPath "$LV/labview" "${@:2}" -Head fails=0 check() { if eval "$2"; then echo "ok $1"; else echo "FAIL $1"; fails=$((fails + 1)); fi; } tree() { find "$LV" -path "$LV/VIObjCache" -prune -o -print | sort; } -# Packages with something to relink that are not marked relinked. One that -# installed no VI, library or LLB has nothing to relink and stays unmarked. -unrelinked() { - python3 - "$1" <<'PY' -import glob, json, sys -for f in sorted(glob.glob(sys.argv[1] + "/*.json")): - m = json.load(open(f)) - if m["relink_folders"] and not m["relinked"]: - print(m["name"]) -PY -} -# The bad VIs in a mass-compile log that a package installed, each once (the -# log names a VI again for every caller that loads it); a VI inside an LLB -# counts for the LLB. Folders a package shares with LabVIEW hold LabVIEW's own -# files too, and those are not what this checks. -ours() { - python3 - "$1" <<'PY' -import re, sys -installed = set(open("/work/installed.txt").read().split("\n")) - {""} -seen = [] -for path in re.findall(r'Path="([^"]*)"', open(sys.argv[1], errors="replace").read()): - p = path - while p not in installed and "/" in p: - p = p.rsplit("/", 1)[0] - if p in installed and path not in seen: - seen.append(path) -print("\n".join(seen)) -PY -} -# What uninstall left behind or took away. LabVIEW writes .aliases, .lvlps and -# .UserState files beside a project or library it opens; those are its own. -leftovers() { - python3 - <<'PY' -import os -before = set(open("/out/tree-before.txt").read().split("\n")) -after = set(open("/out/tree-after.txt").read().split("\n")) -side = lambda p: p.endswith((".aliases", ".lvlps")) or ".UserState" in p -def only_side(d): - files = [os.path.join(r, f) for r, _, fs in os.walk(d) for f in fs] - return files and all(side(f) for f in files) -for p in sorted(after - before): - if not side(p) and not (os.path.isdir(p) and only_side(p)): - print("left behind", p) -for p in sorted(before - after - {""}): - print("taken away ", p) -PY -} -tree > /out/tree-before.txt +tree > $O/tree-before.txt cd /work/global -check "headless install resolves and copies every package" '$L install > /out/install.log 2>&1' -cat /out/install.log -check "a headless install starts no LabVIEW" '! pgrep -x labview >/dev/null' -$L list > /out/list.txt 2>/dev/null; cat /out/list.txt +check "headless install --hooks resolves and copies every package" '$L install --hooks > $O/install.log 2>&1' +cat $O/install.log +$L list > $O/list.txt 2>/dev/null; cat $O/list.txt asked=$(sed -n '/^\[dependencies\]/,$p' lvpm.toml | grep -oE '^[A-Za-z0-9_.-]+' || true) -missing=$(for p in $asked; do grep -q "^$p " /out/list.txt || echo "$p"; done) +missing=$(for p in $asked; do grep -qi "^$p " $O/list.txt || echo "$p"; done) check "lvpm list shows every package asked for" '[ -n "$asked" ] && [ -z "$missing" ]' [ -n "$missing" ] && echo " missing: $missing" +check "every install hook ran" '! grep -q "hooks skipped" $O/list.txt' -# Every folder the packages own, nested ones folded into their parent (the set -# the relink pass walks), and every file they installed. -python3 - <<'PY' +# Every folder the packages own, nested ones folded into their parent: the +# same set the relink pass walks. +python3 - > /work/folders.txt <<'PY' import glob, json -ms = [json.load(open(f)) for f in glob.glob("/var/lib/lvpm/*/installed/*.json")] -dirs = sorted({d for m in ms for d in m["relink_folders"]}) -open("/work/folders.txt", "w").write("".join(d + "\n" for d in dirs if not any(d.startswith(p + "/") for p in dirs))) -open("/work/installed.txt", "w").write("".join(f + "\n" for m in ms for f in m["files"])) +dirs = sorted({d for f in glob.glob("/var/lib/lvpm/*/installed/*.json") for d in json.load(open(f))["relink_folders"]}) +print("\n".join(d for d in dirs if not any(d.startswith(p + "/") for p in dirs))) PY echo "mass compiling $(wc -l < /work/folders.txt) folder(s)" -n=0; ran=yes; bad_total=0 +n=0; mc_ok=yes while read -r d; do - n=$((n + 1)); log=/out/masscompile-$n.log; out=/out/masscompile-$n.out - cli MassCompile -DirectoryToCompile "$d" -MassCompileLogFile "$log" > "$out" 2>&1 - if ! grep -q "MassCompile operation" "$out" || [ ! -f "$log" ]; then - ran=no - echo " $d ... LabVIEWCLI did not compile it" - tail -5 "$out" | sed 's/^/ /' - continue - fi - bad=$(ours "$log") - count=$(printf '%s' "$bad" | grep -c . || true) - others=$(( $(grep -c "Bad VI" "$log" || true) - count )) - note=""; [ "$others" -gt 0 ] && note=" ($others bad VI(s) of LabVIEW's own in this folder, not counted)" - if [ "$count" = 0 ]; then - echo " $d ... ok$note" + n=$((n + 1)); log=$O/masscompile-$n.log + cli MassCompile -DirectoryToCompile "$d" -MassCompileLogFile "$log" > "$O/masscompile-$n.out" 2>&1 + if grep -q "MassCompile operation succeeded" "$O/masscompile-$n.out" && [ -f "$log" ] && ! grep -q "Bad VI" "$log"; then + echo " $d ... ok" else - bad_total=$((bad_total + count)) - echo " $d ... $count bad VI(s)$note" - printf '%s\n' "$bad" | head -10 | sed "s|^$LV/| |" - [ "$count" -gt 10 ] && echo " ... and $((count - 10)) more (masscompile-$n.log)" + mc_ok=no + echo " $d ... FAILED" + { [ -f "$log" ] && grep -E "Bad VI|Search failed" "$log" || tail -5 "$O/masscompile-$n.out"; } | + sort -u | head -12 | sed 's/^ */ /' fi done < /work/folders.txt -check "mass compile finds no bad VI among the packages' files" '[ $ran = yes ] && [ $n -gt 0 ] && [ $bad_total = 0 ]' +check "mass compile reports no bad VI" '[ $mc_ok = yes ] && [ $n -gt 0 ]' -check "relink --all over the installed packages" '$L relink --all > /out/relink.log 2>&1' -grep -E "^\s+/|FAILED|saved|nothing to relink" /out/relink.log | head -40 -check "every package with something to relink is relinked" '[ -z "$(unrelinked "/var/lib/lvpm/*/installed")" ]' -cli CloseLabVIEW > /out/close.log 2>&1 || true +check "relink --all over the installed packages" '$L relink --all > $O/relink.log 2>&1' +grep -E "^\s+/|FAILED|saved" $O/relink.log | head -40 +check "lvpm list shows every package relinked" '! $L list 2>/dev/null | grep -q "NOT relinked"' +cli CloseLabVIEW > $O/close.log 2>&1 || true for _ in $(seq 30); do pgrep -x labview >/dev/null || break; sleep 1; done -check "uninstall --all" '$L uninstall --all > /out/uninstall.log 2>&1' -tree > /out/tree-after.txt -leftovers > /out/leftovers.txt -check "uninstall leaves the LabVIEW tree as it was" '[ ! -s /out/leftovers.txt ]' -head -20 /out/leftovers.txt -side=$(comm -13 /out/tree-before.txt /out/tree-after.txt | grep -cE '\.aliases$|\.lvlps$|\.UserState' || true) -[ "$side" -gt 0 ] && echo " note: $side project file(s) LabVIEW wrote itself are still there" +check "uninstall --all" '$L uninstall --all > $O/uninstall.log 2>&1' +tree > $O/tree-after.txt +check "the LabVIEW tree is as it was before the install" 'diff $O/tree-before.txt $O/tree-after.txt > $O/tree.diff' +[ -s $O/tree.diff ] && head -20 $O/tree.diff cd /work/venv -check "venv create" '$L venv create > /out/venv.log 2>&1' -check "venv install --relink" '$L install --relink > /out/venv-install.log 2>&1' -grep -E "^\+|FAILED|relinking|skipped" /out/venv-install.log | head -40 -check "every venv package with something to relink is relinked" \ - '[ -n "$(ls .project/.lvpm/installed)" ] && [ -z "$(unrelinked .project/.lvpm/installed)" ]' +check "venv create" '$L venv create > $O/venv.log 2>&1' +check "venv install --relink" '$L install --relink > $O/venv-install.log 2>&1' +grep -E "^\+|FAILED|relinking|skipped|not run" $O/venv-install.log | head -40 +check "every venv package relinked" \ + '$L list 2>/dev/null | grep -q " relinked" && ! $L list 2>/dev/null | grep -q "NOT relinked"' echo [ "$fails" = 0 ] && echo "all checks passed" || { echo "$fails check(s) failed; logs in the OUT directory"; exit 1; } SH -[ -n "$KEEP_OUT" ] && echo "logs: $OUT" +# The logs are world-readable: a copy needs no change of owner, and the OUT +# directory the caller named is left as it was. +if [ -n "${OUT:-}" ]; then + mkdir -p "$OUT" + cp -r "$WORK/out/." "$OUT/" + echo "logs: $OUT" +fi exit "$status" diff --git a/src/launch.rs b/src/launch.rs index 23519f8..f659d1e 100644 --- a/src/launch.rs +++ b/src/launch.rs @@ -24,6 +24,12 @@ use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; +/// Where a headless LabVIEW writes what would have been a dialog. +#[cfg(windows)] +const HEADLESS_LOG: &str = "%TEMP%\\LabVIEW_*_headless_*_cur.txt"; +#[cfg(target_os = "linux")] +const HEADLESS_LOG: &str = "/tmp/labview_*_headless_*_cur.txt"; + /// Line ending for the ini lvpm writes: LabVIEW's own on each platform. #[cfg(windows)] const EOL: &str = "\r\n"; @@ -256,13 +262,9 @@ pub fn wait_ready(port: u16, budget: Duration) -> Result<()> { // A headless LabVIEW shows no dialogs; what would have been one is in // its log instead. let hint = if std::env::var_os("LV_RTE_HEADLESS").is_some() { - if cfg!(windows) { - "(headless LabVIEW: see %TEMP%\\LabVIEW_*_headless_*_cur.txt for what went wrong)" - } else { - "(headless LabVIEW: see /tmp/labview_*_headless_*_cur.txt for what went wrong)" - } + format!("(headless LabVIEW: see {HEADLESS_LOG} for what went wrong)") } else { - "(a dialog may be holding the IDE up — check its window)" + "(a dialog may be holding the IDE up — check its window)".to_string() }; bail!( "LabVIEW never answered VI Server on port {port} within {}s (last: {last})\n{hint}", diff --git a/src/viserver.rs b/src/viserver.rs index daf7608..99de88a 100644 --- a/src/viserver.rs +++ b/src/viserver.rs @@ -55,14 +55,20 @@ pub fn vi_server_port(target: &LvTarget) -> u16 { ini_token(target, "server.tcp.port").and_then(|v| v.parse().ok()).unwrap_or(3363) } +/// Whether LabVIEW serves VI Server over TCP when its ini says nothing: on for +/// Windows; off on Linux, where a 2026 Q3 started on an ini without the key +/// never listens (NI's Linux container image sets it for that reason). +#[cfg(windows)] +const VI_SERVER_DEFAULT: bool = true; +#[cfg(target_os = "linux")] +const VI_SERVER_DEFAULT: bool = false; + /// Fail early, and legibly, when a target cannot accept a VI Server client. pub fn check_vi_server(target: &LvTarget) -> Result { let enabled = ini_token(target, "server.tcp.enabled") .map(|v| v.eq_ignore_ascii_case("true")) - // Absent means LabVIEW's own default: on for Windows, off on Linux — - // a 2026 Q3 started on an ini without the key never listens. NI's - // Linux container image sets it for that reason. - .unwrap_or(cfg!(windows)); + // Absent means LabVIEW's own default. + .unwrap_or(VI_SERVER_DEFAULT); if !enabled { bail!( "VI Server is disabled for {} (server.tcp.enabled is not True in {}).\n\ From 7ee8265b5bf3ee840c26487cf57a3df9bff617d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 08:22:18 +0000 Subject: [PATCH 11/12] ci(linux): LUnit's broken example VI is broken on purpose LUnit is the reference package, and its Basic Example ships Test Broken.vi to show how a broken test is reported. The mass compile check now sets exactly that file aside (exit 3 with no other bad VI counts as compiled); any other bad VI still fails it. Roadmap: integration test VIs made for lvpm, so its checks stop depending on third-party packages; and the lvpm issues the first public package runs found (relink walks folders LabVIEW shares, uninstall leaves LabVIEW's project files, relink --all never marks a package with nothing to relink). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JrYr5p1UVnscR92E5K9ZKf --- docs/linux.md | 3 ++- docs/roadmap.md | 37 +++++++++++++++++++++++++++++++++++++ scripts/linux-packages.sh | 11 +++++++++-- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/docs/linux.md b/docs/linux.md index 8f53ba9..e5cf4e1 100644 --- a/docs/linux.md +++ b/docs/linux.md @@ -127,7 +127,8 @@ in progress. - **linux-packages** — `scripts/linux-packages.sh` in the same image, with LUnit and G-Image from the public indexes: a headless `install --hooks` (G-Image needs its PostInstall) in which every hook must run, a LabVIEWCLI - mass compile of every folder they own that must report no bad VI, + mass compile of every folder they own that must report no bad VI (but the + one LUnit's examples ship broken on purpose), `relink --all`, an uninstall that must leave the LabVIEW tree as it was, and the same set relinked in a venv. Its logs are uploaded. diff --git a/docs/roadmap.md b/docs/roadmap.md index 1f7e223..805082e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -206,6 +206,33 @@ workflow is the work. lvpm's own tooling on packages, which is the reading of "modifying LabVIEW code" to settle before that step runs on shared infrastructure. +## Integration test VIs + +The container jobs test lvpm with packages it does not control: +`linux-e2e` with the VIs in `tools/`, `linux-packages` with LUnit and +G-Image as a reference. Neither was made to test an installer, and their +quirks end up in lvpm's checks (LUnit ships a VI broken on purpose, which the +mass compile has to be told about). A package of VIs made for the job would +test exactly what lvpm answers for, on Windows and Linux alike: + +- a caller whose subVI sits in another folder of the package, which relink + must fix, and a caller outside the package that links into ``, for + the venv overlay; +- PreInstall and PostInstall hooks that write a marker file with what they + were handed (package name, files installed); today's hook VI only shows + that it ran. One of them does setup the package cannot work without, as + G-Image's does; +- a class, a library, a malleable VI, an LLB and a project, each once; +- a palette `.mnu`, an `` group and a Target Dir written + with backslashes; +- a file group into a folder LabVIEW itself uses, such as + `/Framework/Providers/`, where relink must stay inside the + package's own files; +- nothing broken, so "no bad VI" stays a plain rule. + +Saved in LabVIEW 2020, like the relink VI, and zipped into a `.vip` by the +test scripts as today. + ## macOS Not supported, and a macOS build stops with a `compile_error!` in `main.rs`. @@ -233,6 +260,16 @@ new branch. - `Replace Mode = If Newer` compares timestamps instead of "write when absent". - Read hook VIs' `error out` back. - A failure part-way through unpacking leaves files behind with no manifest. +- Relink walks a whole Target Dir even when LabVIEW uses it too. LUnit + installs into `resource/Framework/Providers`, and `relink --all` walked all + of it and saved 45 files there; which of them are LabVIEW's own is not + checked. Same logic on Windows. +- Uninstall leaves the `.aliases` and `.UserState` files LabVIEW writes + beside a package's project once it has opened it, and with them the + package's folders. +- `relink --all` never marks a package with nothing to relink, while + `install --relink` does, so `lvpm list` shows such a package + `NOT relinked` for good. - Linux: install, relink, hooks, refresh and venvs verified against LabVIEW 2026 Q3 in NI's container ([linux.md](linux.md)), and run in CI (`.github/workflows/build.yml`). Still open there: `Exclusive_OS` gates, diff --git a/scripts/linux-packages.sh b/scripts/linux-packages.sh index 8fcd920..f353c54 100755 --- a/scripts/linux-packages.sh +++ b/scripts/linux-packages.sh @@ -6,7 +6,7 @@ # 1. a headless `lvpm install --hooks`: G-Image's PostInstall has to run for # its VIs to work, and every hook must have run; # 2. a LabVIEWCLI mass compile of every folder the packages own, which must -# report no bad VI; +# report no bad VI but the one LUnit's examples ship broken on purpose; # 3. `lvpm relink --all` over the same folders; # 4. `lvpm uninstall --all`, which must leave the LabVIEW tree as it found it; # 5. the same set into a venv with `--relink`, relinked in a LabVIEW started @@ -99,12 +99,19 @@ import glob, json dirs = sorted({d for f in glob.glob("/var/lib/lvpm/*/installed/*.json") for d in json.load(open(f))["relink_folders"]}) print("\n".join(d for d in dirs if not any(d.startswith(p + "/") for p in dirs))) PY +# Broken on purpose: LUnit's example of how a broken test is reported. +KNOWN_BROKEN="$LV/examples/Astemes/LUnit/Basic Example/Dummy/Test Broken.vi" echo "mass compiling $(wc -l < /work/folders.txt) folder(s)" n=0; mc_ok=yes while read -r d; do n=$((n + 1)); log=$O/masscompile-$n.log cli MassCompile -DirectoryToCompile "$d" -MassCompileLogFile "$log" > "$O/masscompile-$n.out" 2>&1 - if grep -q "MassCompile operation succeeded" "$O/masscompile-$n.out" && [ -f "$log" ] && ! grep -q "Bad VI" "$log"; then + rc=$? + bad=$( { grep "Bad VI" "$log" 2>/dev/null || true; } | grep -v -F "Path=\"$KNOWN_BROKEN\"" || true) + # Exit 3 is the CLI's "bad VIs found"; with none left once the known one is + # set aside, the folder compiled. + if [ -f "$log" ] && [ -z "$bad" ] && + { { [ $rc = 0 ] && grep -q "MassCompile operation succeeded" "$O/masscompile-$n.out"; } || [ $rc = 3 ]; }; then echo " $d ... ok" else mc_ok=no From 731e742cb4184af8ba5dbdc9add45a850e192769 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 10:00:29 +0000 Subject: [PATCH 12/12] ci(linux): check what lvpm owns instead of mass compiling The mass compile in linux-packages tested the packages and LabVIEW, not lvpm. Its only failures were LUnit's VI broken on purpose and NI's templates beside LUnit's project provider. The compile also writes .aliases and .UserState files beside each project, and those made the uninstall check fail. Instead: - every file in the install records must be on disk after install --hooks; - after uninstall, no file in the LabVIEW tree may be added, gone or changed; the check compared names only; - the venv flow must leave the LabVIEW tree alone. The changed-file check finds what the name check missed: relink saves LabVIEW's own files when a package file lies directly in a folder LabVIEW fills. With one test VI in resource/Framework/Providers, relink saved 44 of LabVIEW's files. LUnit's provider library lies there, so linux-packages stays red until relink is fixed (roadmap). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JrYr5p1UVnscR92E5K9ZKf --- README.md | 2 +- docs/linux.md | 19 ++++++--- docs/roadmap.md | 34 +++++++++------ scripts/linux-packages.sh | 89 ++++++++++++++++++++++----------------- 4 files changed, 85 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 6ad087d..a4a51e3 100644 --- a/README.md +++ b/README.md @@ -436,7 +436,7 @@ fetches them again. | `src/lv-src/relink-package.vi` | The relink VI embedded into the executable at build time (LabVIEW 2020) | | `scripts/install.ps1` | Download, verify and install the latest release | | `scripts/linux-e2e.sh` | End-to-end check against LabVIEW for Linux in NI's container image | -| `scripts/linux-packages.sh` | Public packages on LabVIEW for Linux: install, mass compile, relink, uninstall, venv | +| `scripts/linux-packages.sh` | Public packages on LabVIEW for Linux: install with hooks, relink, uninstall, venv; the LabVIEW tree must come out unchanged | ## License diff --git a/docs/linux.md b/docs/linux.md index e5cf4e1..39e1ead 100644 --- a/docs/linux.md +++ b/docs/linux.md @@ -86,7 +86,9 @@ Preferences `/etc`, System Installed Libraries `/usr/local/lib`, Public Cache LabVIEWCLI on Linux wants `-LabVIEWPath`, the operation name first and `-Headless` last; given `-Headless` in between, it prints its usage and exits 0. A clean mass compile says `MassCompile operation succeeded` and exits 0; a -broken VI shows as `### Bad VI:` in the log, with exit code 3. +broken VI shows as `### Bad VI:` in the log, with exit code 3. For each +project in the folder, the compile writes `.aliases` and +`..UserState/` beside it. ## Verified @@ -126,11 +128,16 @@ in progress. `nationalinstruments/labview:2026q3-linux` (pinned). - **linux-packages** — `scripts/linux-packages.sh` in the same image, with LUnit and G-Image from the public indexes: a headless `install --hooks` - (G-Image needs its PostInstall) in which every hook must run, a LabVIEWCLI - mass compile of every folder they own that must report no bad VI (but the - one LUnit's examples ship broken on purpose), - `relink --all`, an uninstall that must leave the LabVIEW tree as it was, - and the same set relinked in a venv. Its logs are uploaded. + (G-Image needs its PostInstall) in which every hook must run and every + recorded file must be on disk, `relink --all`, an uninstall after which no + file in the LabVIEW tree is added, gone or changed, and the same set + relinked in a venv, which must leave the LabVIEW tree alone. Its logs are + uploaded. + + There is no mass compile. A bad VI in a third-party package (LUnit ships + one on purpose) or in LabVIEW's own templates says nothing about lvpm, and + the compile writes project files into the tree the uninstall check + compares. ## Not done diff --git a/docs/roadmap.md b/docs/roadmap.md index 805082e..fd7a09b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -210,10 +210,11 @@ workflow is the work. The container jobs test lvpm with packages it does not control: `linux-e2e` with the VIs in `tools/`, `linux-packages` with LUnit and -G-Image as a reference. Neither was made to test an installer, and their -quirks end up in lvpm's checks (LUnit ships a VI broken on purpose, which the -mass compile has to be told about). A package of VIs made for the job would -test exactly what lvpm answers for, on Windows and Linux alike: +G-Image as a reference. Neither was made to test an installer: LUnit ships a +VI broken on purpose, so `linux-packages` cannot ask whether a package's VIs +work, only whether lvpm put them in place and took them out again. A package +of VIs made for the job would test exactly what lvpm answers for, on Windows +and Linux alike: - a caller whose subVI sits in another folder of the package, which relink must fix, and a caller outside the package that links into ``, for @@ -226,9 +227,11 @@ test exactly what lvpm answers for, on Windows and Linux alike: - a palette `.mnu`, an `` group and a Target Dir written with backslashes; - a file group into a folder LabVIEW itself uses, such as - `/Framework/Providers/`, where relink must stay inside the + `/Framework/Providers/`, and a file directly in such a + folder, as LUnit's provider library is, where relink must stay inside the package's own files; -- nothing broken, so "no bad VI" stays a plain rule. +- nothing broken, so the job can run every VI (`vi-run`) and fail on any + error. Saved in LabVIEW 2020, like the relink VI, and zipped into a `.vip` by the test scripts as today. @@ -260,13 +263,18 @@ new branch. - `Replace Mode = If Newer` compares timestamps instead of "write when absent". - Read hook VIs' `error out` back. - A failure part-way through unpacking leaves files behind with no manifest. -- Relink walks a whole Target Dir even when LabVIEW uses it too. LUnit - installs into `resource/Framework/Providers`, and `relink --all` walked all - of it and saved 45 files there; which of them are LabVIEW's own is not - checked. Same logic on Windows. -- Uninstall leaves the `.aliases` and `.UserState` files LabVIEW writes - beside a package's project once it has opened it, and with them the - package's folders. +- Relink saves LabVIEW's own files. The relink VI takes folders and walks + their subfolders too, and a package file that lies directly in a folder + LabVIEW fills puts that whole folder in the walk. LUnit's + `LUnit Project Provider.lvlib` lies in `resource/Framework/Providers`; a + single test VI there made relink save 44 of LabVIEW's files (Actor + Framework and AppBuilder providers), still changed after the uninstall. + `linux-packages` fails on this. Same code on Windows. Fix: a relink VI + that takes the package's files, or no walk of a folder that holds files + of others. +- Uninstall leaves the `.aliases` and `.UserState` files that LabVIEW writes + beside a package's project (a mass compile of its folder writes them), and + with them the package's folders. - `relink --all` never marks a package with nothing to relink, while `install --relink` does, so `lvpm list` shows such a package `NOT relinked` for good. diff --git a/scripts/linux-packages.sh b/scripts/linux-packages.sh index f353c54..44f81d0 100755 --- a/scripts/linux-packages.sh +++ b/scripts/linux-packages.sh @@ -5,12 +5,17 @@ # container: # 1. a headless `lvpm install --hooks`: G-Image's PostInstall has to run for # its VIs to work, and every hook must have run; -# 2. a LabVIEWCLI mass compile of every folder the packages own, which must -# report no bad VI but the one LUnit's examples ship broken on purpose; -# 3. `lvpm relink --all` over the same folders; -# 4. `lvpm uninstall --all`, which must leave the LabVIEW tree as it found it; +# 2. every file in the install records must be on disk; +# 3. `lvpm relink --all` over the packages' folders; +# 4. `lvpm uninstall --all`, after which the LabVIEW tree must be as it was: +# no file added, gone or changed; # 5. the same set into a venv with `--relink`, relinked in a LabVIEW started -# on the venv (a venv runs no hooks). +# on the venv (a venv runs no hooks), which must leave the LabVIEW tree +# alone. +# +# Whether the packages' VIs are broken is not asked: a bad VI in a package, or +# in LabVIEW's own templates, says nothing about lvpm, and a mass compile +# writes into the tree the uninstall check compares (docs/linux.md). # # scripts/linux-packages.sh [image] default: nationalinstruments/labview:latest-linux # @@ -80,8 +85,27 @@ cli() { LabVIEWCLI -OperationName "$1" -LabVIEWPath "$LV/labview" "${@:2}" -Head fails=0 check() { if eval "$2"; then echo "ok $1"; else echo "FAIL $1"; fails=$((fails + 1)); fi; } tree() { find "$LV" -path "$LV/VIObjCache" -prune -o -print | sort; } +# The LabVIEW tree against snapshot $1: no name added or gone, and no file +# that was there written since. VIObjCache is LabVIEW's compile cache. +snapshot() { tree > $O/$1-before.txt; touch /tmp/$1.marker; sleep 1; } +untouched() { + tree > $O/$1-after.txt + diff $O/$1-before.txt $O/$1-after.txt > $O/$1-tree.diff + find "$LV" -path "$LV/VIObjCache" -prune -o -type f -newer /tmp/$1.marker -print | sort | + comm -12 - $O/$1-before.txt > $O/$1-changed.txt + [ ! -s $O/$1-tree.diff ] && [ ! -s $O/$1-changed.txt ] +} +show_changes() { + [ -s $O/$1-tree.diff ] && { echo " added or gone:"; head -20 $O/$1-tree.diff; } + [ -s $O/$1-changed.txt ] && { echo " changed: $(wc -l < $O/$1-changed.txt) file(s)"; head -20 $O/$1-changed.txt; } + true +} +close_labview() { + cli CloseLabVIEW >> $O/close.log 2>&1 || true + for _ in $(seq 30); do pgrep -x labview >/dev/null || break; sleep 1; done +} -tree > $O/tree-before.txt +snapshot global cd /work/global check "headless install --hooks resolves and copies every package" '$L install --hooks > $O/install.log 2>&1' cat $O/install.log @@ -92,53 +116,40 @@ check "lvpm list shows every package asked for" '[ -n "$asked" ] && [ -z "$missi [ -n "$missing" ] && echo " missing: $missing" check "every install hook ran" '! grep -q "hooks skipped" $O/list.txt' -# Every folder the packages own, nested ones folded into their parent: the -# same set the relink pass walks. -python3 - > /work/folders.txt <<'PY' -import glob, json -dirs = sorted({d for f in glob.glob("/var/lib/lvpm/*/installed/*.json") for d in json.load(open(f))["relink_folders"]}) -print("\n".join(d for d in dirs if not any(d.startswith(p + "/") for p in dirs))) +python3 - > $O/placement.txt <<'PY' +import glob, json, os +records = [json.load(open(r)) for r in sorted(glob.glob("/var/lib/lvpm/*/installed/*.json"))] +files = [(m["name"], f) for m in records for f in m["files"]] +print(f"{len(files)} file(s) in {len(records)} install record(s)") +for name, f in files: + if not os.path.lexists(f): + print(f"not on disk: {name}: {f}") PY -# Broken on purpose: LUnit's example of how a broken test is reported. -KNOWN_BROKEN="$LV/examples/Astemes/LUnit/Basic Example/Dummy/Test Broken.vi" -echo "mass compiling $(wc -l < /work/folders.txt) folder(s)" -n=0; mc_ok=yes -while read -r d; do - n=$((n + 1)); log=$O/masscompile-$n.log - cli MassCompile -DirectoryToCompile "$d" -MassCompileLogFile "$log" > "$O/masscompile-$n.out" 2>&1 - rc=$? - bad=$( { grep "Bad VI" "$log" 2>/dev/null || true; } | grep -v -F "Path=\"$KNOWN_BROKEN\"" || true) - # Exit 3 is the CLI's "bad VIs found"; with none left once the known one is - # set aside, the folder compiled. - if [ -f "$log" ] && [ -z "$bad" ] && - { { [ $rc = 0 ] && grep -q "MassCompile operation succeeded" "$O/masscompile-$n.out"; } || [ $rc = 3 ]; }; then - echo " $d ... ok" - else - mc_ok=no - echo " $d ... FAILED" - { [ -f "$log" ] && grep -E "Bad VI|Search failed" "$log" || tail -5 "$O/masscompile-$n.out"; } | - sort -u | head -12 | sed 's/^ */ /' - fi -done < /work/folders.txt -check "mass compile reports no bad VI" '[ $mc_ok = yes ] && [ $n -gt 0 ]' +head -1 $O/placement.txt +check "every file in the install records is on disk" \ + '! grep -q "^0 file" $O/placement.txt && ! grep -q "^not on disk" $O/placement.txt' +grep "^not on disk" $O/placement.txt | head -20 check "relink --all over the installed packages" '$L relink --all > $O/relink.log 2>&1' grep -E "^\s+/|FAILED|saved" $O/relink.log | head -40 check "lvpm list shows every package relinked" '! $L list 2>/dev/null | grep -q "NOT relinked"' -cli CloseLabVIEW > $O/close.log 2>&1 || true -for _ in $(seq 30); do pgrep -x labview >/dev/null || break; sleep 1; done +close_labview check "uninstall --all" '$L uninstall --all > $O/uninstall.log 2>&1' -tree > $O/tree-after.txt -check "the LabVIEW tree is as it was before the install" 'diff $O/tree-before.txt $O/tree-after.txt > $O/tree.diff' -[ -s $O/tree.diff ] && head -20 $O/tree.diff +# The uninstall hooks can start LabVIEW; what it writes as it closes counts too. +close_labview +check "the LabVIEW tree is as it was: no file added, gone or changed" 'untouched global' +show_changes global cd /work/venv +snapshot venv check "venv create" '$L venv create > $O/venv.log 2>&1' check "venv install --relink" '$L install --relink > $O/venv-install.log 2>&1' grep -E "^\+|FAILED|relinking|skipped|not run" $O/venv-install.log | head -40 check "every venv package relinked" \ '$L list 2>/dev/null | grep -q " relinked" && ! $L list 2>/dev/null | grep -q "NOT relinked"' +check "the venv left the LabVIEW tree alone" 'untouched venv' +show_changes venv echo [ "$fails" = 0 ] && echo "all checks passed" || { echo "$fails check(s) failed; logs in the OUT directory"; exit 1; }