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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ inference-cli = { path = "./core/cli", version = "0.0.1" }
inference-wasm-to-v-translator = { path = "./core/wasm-to-v", version = "0.0.1" }
inference-wasm-codegen = { path = "./core/wasm-codegen", version = "0.0.1" }
inference-analysis = { path = "./core/analysis", version = "0.0.1" }
inference-compiler-interface = { path = "./core/compiler-interface", version = "0.0.1" }

# IDE support crates
inference-base-db = { path = "./ide/base-db", version = "0.0.1" }
Expand Down
10 changes: 6 additions & 4 deletions apps/infs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ path = "src/main.rs"
[dependencies]
clap = { version = "4.5", features = ["derive"] }
reqwest = { version = "0.13.1", default-features = false, features = ["rustls", "stream"] }
sha2 = "0.10"
zip = { version = "7.1.0", default-features = false, features = ["deflate"] }
sha2 = "0.11"
zip = { version = "8.5.1", default-features = false, features = ["deflate"] }
flate2 = "1"
tar = "0.4"
semver = "1.0"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.9.8"
toml = "1.1.2"
hex = "0.4"
futures-util = "0.3"
which = "8.0.0"
Expand All @@ -34,15 +34,17 @@ ratatui = "0.30.0"
crossterm = "0.29.0"
anyhow.workspace = true
thiserror.workspace = true
inference-compiler-interface.workspace = true

[target.'cfg(windows)'.dependencies]
winreg = "0.55"
winreg = "0.56"

[dev-dependencies]
assert_cmd = "2.1.1"
predicates = "3.1.3"
assert_fs = "1.1.1"
serial_test = "3.2"
regex = "1"

[profile.release]
opt-level = "z" # Optimize for size (not speed)
Expand Down
222 changes: 218 additions & 4 deletions apps/infs/src/commands/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@

use anyhow::{Context, Result, bail};
use clap::Args;
use std::path::PathBuf;
use std::process::Command;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

use crate::errors::InfsError;
use crate::toolchain::find_infc;
use inference_compiler_interface::{COMPILER_ABI_MAJOR, COMPILER_ABI_MINOR};

/// Arguments for the build command.
///
Expand All @@ -43,21 +44,25 @@ pub struct BuildArgs {
///
/// 1. Validates that the source file exists
/// 2. Locates the infc compiler binary
/// 3. Builds and executes the infc command, forwarding `-v` if requested
/// 4. Propagates exit code from infc
/// 3. Runs the `infc` compatibility handshake (git hash + ABI version)
/// 4. Builds and executes the infc command, forwarding `-v` if requested
/// 5. Propagates exit code from infc
///
/// ## Errors
///
/// Returns an error if:
/// - The source file does not exist
/// - infc compiler cannot be found
/// - infc reports a *major* ABI version mismatch (hard error with remediation)
/// - infc exits with non-zero code (as `InfsError::ProcessExitCode`)
pub fn execute(args: &BuildArgs) -> Result<()> {
if !args.path.exists() {
bail!("Path not found: {}", args.path.display());
}

let infc_path = find_infc()?;
check_compiler_compatibility(&infc_path)?;

let mut cmd = Command::new(&infc_path);
cmd.arg(&args.path);

Expand All @@ -79,3 +84,212 @@ pub fn execute(args: &BuildArgs) -> Result<()> {
Err(InfsError::process_exit_code(code).into())
}
}

/// Runs a compatibility handshake against the resolved `infc` binary.
///
/// Sequence:
/// 1. Query `infc --commit-hash`. If it equals `INFS_GIT_COMMIT`, short-circuit —
/// the two binaries were built from the same source tree and the ABI is
/// guaranteed compatible.
/// 2. Otherwise query `infc --abi-version` and compare against the major/minor
/// constants from `inference-compiler-interface`. Major mismatch is a hard
/// error; minor mismatch is a warning; exact match is silent.
///
/// Old binaries that do not understand the flags (non-zero exit, empty output,
/// or the literal `unknown`) are treated as graceful skips — we neither warn
/// nor error on them. The L1/L2 resolver fixes remain the correctness
/// guarantee; this handshake is a safety net against residual drift.
fn check_compiler_compatibility(infc_path: &Path) -> Result<()> {
// --commit-hash / --abi-version print and exit 0 immediately; no timeout needed.
let local_commit = env!("INFS_GIT_COMMIT");
let remote_commit = probe_flag(infc_path, "--commit-hash");

if let Some(hash) = &remote_commit
&& hash == local_commit
{
return Ok(());
}

let Some(abi_raw) = probe_flag(infc_path, "--abi-version") else {
return Ok(());
};

let Some((infc_major, infc_minor)) = parse_abi_version(&abi_raw) else {
return Ok(());
};

let local_major = COMPILER_ABI_MAJOR;
let local_minor = COMPILER_ABI_MINOR;

if infc_major != local_major {
bail!(
"infs ABI {local_major}.{local_minor} but infc reported ABI \
{infc_major}.{infc_minor}; rebuild the workspace or set \
INFC_PATH to a matching binary."
);
}

match infc_minor.cmp(&local_minor) {
std::cmp::Ordering::Greater => {
eprintln!(
"warning: infc ABI {infc_major}.{infc_minor} is newer than \
infs ABI {local_major}.{local_minor}; infs may not \
recognize features emitted by infc."
);
}
std::cmp::Ordering::Less => {
eprintln!(
"warning: infs ABI {local_major}.{local_minor} is newer \
than infc ABI {infc_major}.{infc_minor}; infs may request \
features infc does not provide."
);
}
std::cmp::Ordering::Equal => {}
}

Ok(())
}

/// Runs `<infc_path> <flag>` with stdin/stderr suppressed and returns the
/// trimmed stdout on success. Returns `None` for any failure mode that an old
/// `infc` lacking the flag would produce: spawn error, non-zero exit, empty
/// stdout, or the literal `unknown`.
fn probe_flag(infc_path: &Path, flag: &str) -> Option<String> {
let output = Command::new(infc_path)
.arg(flag)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.ok()?;
if !output.status.success() {
return None;
}
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
if value.is_empty() || value == "unknown" {
return None;
}
Some(value)
}

/// Parses a `"<major>.<minor>"` string into `(major, minor)`. Returns `None`
/// on any parse failure — callers treat that as "skip the ABI check".
fn parse_abi_version(raw: &str) -> Option<(u32, u32)> {
let (major, minor) = raw.split_once('.')?;
let major: u32 = major.parse().ok()?;
let minor: u32 = minor.parse().ok()?;
Some((major, minor))
}

#[cfg(all(test, unix))]
mod tests {
use super::*;
use assert_fs::prelude::*;
use std::os::unix::fs::PermissionsExt;

/// Writes an executable `infc` stub that prints fixed strings for
/// `--commit-hash` and `--abi-version`. The stub exits 0 by default but
/// can be configured to exit 1 instead.
fn write_stub(
dir: &assert_fs::TempDir,
commit_stdout: &str,
abi_stdout: &str,
exit_nonzero: bool,
) -> PathBuf {
let stub = dir.child("infc");
let exit_code = i32::from(exit_nonzero);
let script = format!(
"#!/bin/sh\n\
case \"$1\" in\n\
--commit-hash)\n\
printf '%s\\n' \"{commit_stdout}\"\n\
exit {exit_code}\n\
;;\n\
--abi-version)\n\
printf '%s\\n' \"{abi_stdout}\"\n\
exit {exit_code}\n\
;;\n\
*)\n\
exit 0\n\
;;\n\
esac\n",
);
stub.write_str(&script).unwrap();
let mut perms = std::fs::metadata(stub.path()).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(stub.path(), perms).unwrap();
stub.path().to_path_buf()
}

#[test]
fn abi_major_mismatch_is_hard_error() {
let dir = assert_fs::TempDir::new().unwrap();
let stub = write_stub(&dir, "nottherightcommit", "2.0", false);
let err = check_compiler_compatibility(&stub).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("ABI") && msg.contains("rebuild"),
"expected remediation message, got: {msg}"
);
}

#[test]
fn abi_minor_difference_warns_only() {
let dir = assert_fs::TempDir::new().unwrap();
// Exercise only the "infc minor newer than infs" path; the reverse
// path is covered by a simple construction — the current embedded
// minor is 0, so any non-zero minor is "newer". A stub of "1.5"
// therefore produces a warning when COMPILER_ABI_MINOR is 0.
let stub = write_stub(&dir, "nottherightcommit", "1.5", false);
let result = check_compiler_compatibility(&stub);
assert!(result.is_ok(), "minor mismatch should not hard-error");
}

#[test]
fn matching_commit_hash_skips_abi_check() {
let dir = assert_fs::TempDir::new().unwrap();
// ABI "9.9" would trigger a major mismatch if the ABI check ran.
// A matching commit hash must short-circuit before that.
let stub = write_stub(&dir, env!("INFS_GIT_COMMIT"), "9.9", false);
let result = check_compiler_compatibility(&stub);
assert!(
result.is_ok(),
"matching commit hash must short-circuit ABI check, got: {:?}",
result.err().map(|e| e.to_string()),
);
}

#[test]
fn unknown_commit_and_unknown_abi_is_silent() {
let dir = assert_fs::TempDir::new().unwrap();
let stub = write_stub(&dir, "unknown", "unknown", false);
let result = check_compiler_compatibility(&stub);
assert!(result.is_ok(), "unknown outputs must be graceful");
}

#[test]
fn old_infc_returns_nonzero_for_flags_is_graceful() {
let dir = assert_fs::TempDir::new().unwrap();
let stub = write_stub(&dir, "anything", "anything", true);
let result = check_compiler_compatibility(&stub);
assert!(
result.is_ok(),
"non-zero exit from flag probes must be graceful"
);
}

#[test]
fn parse_abi_version_accepts_valid() {
assert_eq!(parse_abi_version("1.0"), Some((1, 0)));
assert_eq!(parse_abi_version("2.7"), Some((2, 7)));
}

#[test]
fn parse_abi_version_rejects_garbage() {
assert_eq!(parse_abi_version(""), None);
assert_eq!(parse_abi_version("1"), None);
assert_eq!(parse_abi_version("1.x"), None);
assert_eq!(parse_abi_version("x.1"), None);
assert_eq!(parse_abi_version("1.0.0"), None);
}
}
Loading
Loading