chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "jcode-build-support"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
jcode-core = { path = "../jcode-core" }
|
||||
jcode-selfdev-types = { path = "../jcode-selfdev-types" }
|
||||
jcode-storage = { path = "../jcode-storage" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tempfile = "3"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Write the dev-binary source metadata sidecar for the current repo state.
|
||||
//!
|
||||
//! Self-dev helper: after a direct `scripts/dev_cargo.sh build` (outside the
|
||||
//! coordinated build queue), the freshly built `target/selfdev/jcode` has no
|
||||
//! up-to-date `.source.json` sidecar, so `selfdev reload` refuses to publish
|
||||
//! it. Run this to stamp the binary with the *current* source state:
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run -p jcode-build-support --example write_dev_sidecar
|
||||
//! ```
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let repo = std::env::current_dir()?;
|
||||
let state = jcode_build_support::current_source_state(&repo)?;
|
||||
let path = jcode_build_support::write_current_dev_binary_source_metadata(&repo, &state)?;
|
||||
println!("wrote {} for {}", path.display(), state.version_label);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,924 @@
|
||||
mod paths;
|
||||
mod platform_support;
|
||||
mod source_state;
|
||||
mod storage_helpers;
|
||||
|
||||
pub use paths::{
|
||||
SELFDEV_CARGO_PROFILE, binary_name, binary_stem, client_update_candidate,
|
||||
current_binary_build_time_string, current_binary_built_at, find_dev_binary,
|
||||
find_repo_in_ancestors, get_repo_dir, is_jcode_repo, launcher_binary_path, launcher_dir,
|
||||
preferred_reload_candidate, release_binary_path, resolve_binary_payload, run_selfdev_build,
|
||||
selfdev_binary_path, selfdev_build_command, selfdev_build_command_for_target,
|
||||
shared_server_update_candidate, update_launcher_symlink_to_current,
|
||||
update_launcher_symlink_to_stable, version_matches_installed_channel,
|
||||
};
|
||||
pub use source_state::{
|
||||
current_build_info, current_git_diff, current_git_hash, current_git_hash_full,
|
||||
current_source_state, ensure_source_state_matches, get_commit_message, is_working_tree_dirty,
|
||||
repo_build_version, repo_scope_key, worktree_scope_key,
|
||||
};
|
||||
pub use storage_helpers::{
|
||||
build_log_path, build_progress_path, builds_dir, canary_binary_path, clear_build_progress,
|
||||
clear_migration_context, current_binary_path, current_version_file, load_migration_context,
|
||||
manifest_path, migration_context_path, read_build_progress, read_current_version,
|
||||
read_shared_server_version, read_stable_version, save_migration_context,
|
||||
shared_server_binary_path, shared_server_version_file, stable_binary_path, stable_version_file,
|
||||
version_binary_path, write_build_progress,
|
||||
};
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use jcode_storage as storage;
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(unix)]
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
#[cfg(unix)]
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub use jcode_selfdev_types::{
|
||||
BinaryChoice, BinaryVersionReport, BuildInfo, CanaryStatus, CrashInfo, DevBinarySourceMetadata,
|
||||
MigrationContext, PendingActivation, PublishedBuild, SelfDevBuildCommand, SelfDevBuildTarget,
|
||||
SourceState,
|
||||
};
|
||||
|
||||
/// Manifest tracking build versions and their status
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct BuildManifest {
|
||||
/// Current stable build hash (known good)
|
||||
pub stable: Option<String>,
|
||||
/// Current canary build hash (being tested)
|
||||
pub canary: Option<String>,
|
||||
/// Session ID testing the canary build
|
||||
pub canary_session: Option<String>,
|
||||
/// Status of canary testing
|
||||
pub canary_status: Option<CanaryStatus>,
|
||||
/// History of recent builds
|
||||
#[serde(default)]
|
||||
pub history: Vec<BuildInfo>,
|
||||
/// Last crash information (if canary crashed)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_crash: Option<CrashInfo>,
|
||||
/// Pending activation being validated across reload/resume.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pending_activation: Option<PendingActivation>,
|
||||
}
|
||||
|
||||
impl BuildManifest {
|
||||
/// Load manifest from disk
|
||||
pub fn load() -> Result<Self> {
|
||||
let path = manifest_path()?;
|
||||
if path.exists() {
|
||||
storage::read_json(&path)
|
||||
} else {
|
||||
Ok(Self::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// Save manifest to disk
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let path = manifest_path()?;
|
||||
storage::write_json(&path, self)
|
||||
}
|
||||
|
||||
/// Check if we should use stable or canary for a given session
|
||||
pub fn binary_for_session(&self, session_id: &str) -> BinaryChoice {
|
||||
// If this session is the canary tester, use canary
|
||||
if let Some(ref canary_session) = self.canary_session
|
||||
&& canary_session == session_id
|
||||
&& let Some(ref canary) = self.canary
|
||||
{
|
||||
return BinaryChoice::Canary(canary.clone());
|
||||
}
|
||||
// Otherwise use stable
|
||||
if let Some(ref stable) = self.stable {
|
||||
BinaryChoice::Stable(stable.clone())
|
||||
} else {
|
||||
BinaryChoice::Current
|
||||
}
|
||||
}
|
||||
|
||||
/// Start canary testing for a session
|
||||
pub fn start_canary(&mut self, hash: &str, session_id: &str) -> Result<()> {
|
||||
self.canary = Some(hash.to_string());
|
||||
self.canary_session = Some(session_id.to_string());
|
||||
self.canary_status = Some(CanaryStatus::Testing);
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// Mark canary as passed
|
||||
pub fn mark_canary_passed(&mut self) -> Result<()> {
|
||||
self.canary_status = Some(CanaryStatus::Passed);
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// Mark canary as failed
|
||||
pub fn mark_canary_failed(&mut self) -> Result<()> {
|
||||
self.canary_status = Some(CanaryStatus::Failed);
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// Record a crash
|
||||
pub fn record_crash(
|
||||
&mut self,
|
||||
hash: &str,
|
||||
exit_code: i32,
|
||||
stderr: &str,
|
||||
diff: Option<String>,
|
||||
) -> Result<()> {
|
||||
self.last_crash = Some(CrashInfo {
|
||||
build_hash: hash.to_string(),
|
||||
exit_code,
|
||||
stderr: stderr.chars().take(4096).collect(), // Truncate
|
||||
crashed_at: Utc::now(),
|
||||
diff,
|
||||
});
|
||||
self.canary_status = Some(CanaryStatus::Failed);
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// Clear crash info after it's been handled
|
||||
pub fn clear_crash(&mut self) -> Result<()> {
|
||||
self.last_crash = None;
|
||||
self.save()
|
||||
}
|
||||
|
||||
pub fn set_pending_activation(&mut self, activation: PendingActivation) -> Result<()> {
|
||||
self.pending_activation = Some(activation);
|
||||
self.save()
|
||||
}
|
||||
|
||||
pub fn clear_pending_activation(&mut self) -> Result<()> {
|
||||
self.pending_activation = None;
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// Add build to history
|
||||
pub fn add_to_history(&mut self, info: BuildInfo) -> Result<()> {
|
||||
// Keep last 20 builds
|
||||
self.history.insert(0, info);
|
||||
self.history.truncate(20);
|
||||
self.save()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn complete_pending_activation_for_session(session_id: &str) -> Result<Option<String>> {
|
||||
let mut manifest = BuildManifest::load()?;
|
||||
let Some(pending) = manifest.pending_activation.clone() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if pending.session_id != session_id {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
manifest.canary = Some(pending.new_version.clone());
|
||||
manifest.canary_session = Some(session_id.to_string());
|
||||
manifest.canary_status = Some(CanaryStatus::Passed);
|
||||
manifest.pending_activation = None;
|
||||
manifest.last_crash = None;
|
||||
manifest.save()?;
|
||||
Ok(Some(pending.new_version))
|
||||
}
|
||||
|
||||
pub fn rollback_pending_activation_for_session(session_id: &str) -> Result<Option<String>> {
|
||||
let mut manifest = BuildManifest::load()?;
|
||||
let Some(pending) = manifest.pending_activation.clone() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if pending.session_id != session_id {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Some(previous) = pending.previous_current_version.as_deref() {
|
||||
update_current_symlink(previous)?;
|
||||
update_launcher_symlink_to_current()?;
|
||||
}
|
||||
if let Some(previous) = pending.previous_shared_server_version.as_deref() {
|
||||
update_shared_server_symlink(previous)?;
|
||||
}
|
||||
manifest.canary_status = Some(CanaryStatus::Failed);
|
||||
manifest.pending_activation = None;
|
||||
manifest.save()?;
|
||||
Ok(Some(pending.new_version))
|
||||
}
|
||||
|
||||
/// Install a binary at a specific immutable version path.
|
||||
pub fn install_binary_at_version(source: &std::path::Path, version: &str) -> Result<PathBuf> {
|
||||
if !source.exists() {
|
||||
anyhow::bail!("Binary not found at {:?}", source);
|
||||
}
|
||||
|
||||
let dest_dir = builds_dir()?.join("versions").join(version);
|
||||
storage::ensure_dir(&dest_dir)?;
|
||||
|
||||
let dest = dest_dir.join(binary_name());
|
||||
|
||||
// Remove existing file first to avoid ETXTBSY when replacing a running binary.
|
||||
if dest.exists() {
|
||||
std::fs::remove_file(&dest)?;
|
||||
}
|
||||
|
||||
// Prefer hard link (instant, zero I/O) over copy (71MB+ binary).
|
||||
// Falls back to copy if hard link fails (e.g. cross-filesystem).
|
||||
if std::fs::hard_link(source, &dest).is_err() {
|
||||
std::fs::copy(source, &dest)?;
|
||||
}
|
||||
crate::platform_support::set_permissions_executable(&dest)?;
|
||||
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
fn binary_source_metadata_path(binary: &Path) -> PathBuf {
|
||||
let file_name = binary
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| binary_stem().to_string());
|
||||
binary.with_file_name(format!("{file_name}.source.json"))
|
||||
}
|
||||
|
||||
pub fn write_dev_binary_source_metadata(binary: &Path, source: &SourceState) -> Result<PathBuf> {
|
||||
let path = binary_source_metadata_path(binary);
|
||||
storage::write_json(&path, &DevBinarySourceMetadata::from(source))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn write_current_dev_binary_source_metadata(
|
||||
repo_dir: &Path,
|
||||
source: &SourceState,
|
||||
) -> Result<PathBuf> {
|
||||
let binary = find_dev_binary(repo_dir)
|
||||
.ok_or_else(|| anyhow::anyhow!("Binary not found in target/selfdev or target/release"))?;
|
||||
write_dev_binary_source_metadata(&binary, source)
|
||||
}
|
||||
|
||||
fn read_binary_version_report(binary: &Path) -> Result<BinaryVersionReport> {
|
||||
let output = Command::new(binary)
|
||||
.args(["version", "--json"])
|
||||
.env("JCODE_NON_INTERACTIVE", "1")
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"Binary smoke test failed for {} with exit code {:?}: {}",
|
||||
binary.display(),
|
||||
output.status.code(),
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
serde_json::from_slice(&output.stdout).map_err(|err| {
|
||||
anyhow::anyhow!(
|
||||
"Binary smoke test for {} returned invalid JSON: {}",
|
||||
binary.display(),
|
||||
err
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn smoke_test_binary(binary: &Path) -> Result<()> {
|
||||
let report = read_binary_version_report(binary)?;
|
||||
if report.version.as_deref().unwrap_or_default().is_empty() {
|
||||
anyhow::bail!(
|
||||
"Binary smoke test for {} returned JSON without a version field",
|
||||
binary.display()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_binary_version_matches_source_report(
|
||||
report: &BinaryVersionReport,
|
||||
binary: &Path,
|
||||
source: &SourceState,
|
||||
) -> Result<()> {
|
||||
let git_hash = report.git_hash.as_deref().unwrap_or_default();
|
||||
if git_hash.is_empty() {
|
||||
anyhow::bail!(
|
||||
"Binary {} version report did not include git_hash; rebuild before publishing {}",
|
||||
binary.display(),
|
||||
source.version_label
|
||||
);
|
||||
}
|
||||
if git_hash != source.short_hash {
|
||||
anyhow::bail!(
|
||||
"Refusing to publish {} as {}: binary was built from git hash {}, but source state is {}",
|
||||
binary.display(),
|
||||
source.version_label,
|
||||
git_hash,
|
||||
source.short_hash
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dirty_status_paths(repo_dir: &Path) -> Result<Vec<(PathBuf, bool)>> {
|
||||
let output = Command::new("git")
|
||||
.args(["status", "--porcelain=v1", "-z", "--untracked-files=all"])
|
||||
.current_dir(repo_dir)
|
||||
.output()?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"git status failed while validating dirty build freshness with status {:?}",
|
||||
output.status.code()
|
||||
);
|
||||
}
|
||||
|
||||
let mut entries = output.stdout.split(|byte| *byte == 0).peekable();
|
||||
let mut paths = Vec::new();
|
||||
while let Some(entry) = entries.next() {
|
||||
if entry.is_empty() || entry.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
let x = entry[0];
|
||||
let y = entry[1];
|
||||
let path = String::from_utf8_lossy(&entry[3..]).to_string();
|
||||
let deleted = x == b'D' || y == b'D';
|
||||
paths.push((PathBuf::from(path), deleted));
|
||||
|
||||
if matches!(x, b'R' | b'C') || matches!(y, b'R' | b'C') {
|
||||
let _ = entries.next();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
fn validate_dirty_binary_freshness_without_metadata(
|
||||
repo_dir: &Path,
|
||||
binary: &Path,
|
||||
source: &SourceState,
|
||||
) -> Result<()> {
|
||||
if !source.dirty {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let binary_mtime = std::fs::metadata(binary)
|
||||
.and_then(|metadata| metadata.modified())
|
||||
.map_err(|err| {
|
||||
anyhow::anyhow!(
|
||||
"Could not read binary modification time for {}: {}",
|
||||
binary.display(),
|
||||
err
|
||||
)
|
||||
})?;
|
||||
let dirty_paths = dirty_status_paths(repo_dir)?;
|
||||
let mut unverifiable = Vec::new();
|
||||
let mut newer_than_binary = Vec::new();
|
||||
|
||||
for (relative, deleted) in dirty_paths {
|
||||
if deleted {
|
||||
unverifiable.push(relative.display().to_string());
|
||||
continue;
|
||||
}
|
||||
let path = repo_dir.join(&relative);
|
||||
let modified = match std::fs::metadata(&path).and_then(|metadata| metadata.modified()) {
|
||||
Ok(modified) => modified,
|
||||
Err(_) => {
|
||||
unverifiable.push(relative.display().to_string());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if modified > binary_mtime {
|
||||
newer_than_binary.push(relative.display().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if !unverifiable.is_empty() {
|
||||
anyhow::bail!(
|
||||
"Refusing to publish dirty build {} without source metadata: these changed paths cannot be checked against the binary timestamp: {}",
|
||||
source.version_label,
|
||||
unverifiable.join(", ")
|
||||
);
|
||||
}
|
||||
if !newer_than_binary.is_empty() {
|
||||
anyhow::bail!(
|
||||
"Refusing to publish stale dirty build {}: changed paths are newer than {}: {}",
|
||||
source.version_label,
|
||||
binary.display(),
|
||||
newer_than_binary.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_dev_binary_source_metadata(binary: &Path, source: &SourceState) -> Result<bool> {
|
||||
let path = binary_source_metadata_path(binary);
|
||||
if !path.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let metadata: DevBinarySourceMetadata = storage::read_json(&path)?;
|
||||
if metadata.source_fingerprint != source.fingerprint
|
||||
|| metadata.version_label != source.version_label
|
||||
|| metadata.short_hash != source.short_hash
|
||||
|| metadata.full_hash != source.full_hash
|
||||
|| metadata.dirty != source.dirty
|
||||
{
|
||||
anyhow::bail!(
|
||||
"Refusing to publish {} as {}: source metadata at {} was for {} ({})",
|
||||
binary.display(),
|
||||
source.version_label,
|
||||
path.display(),
|
||||
metadata.version_label,
|
||||
metadata.source_fingerprint
|
||||
);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn validate_dev_binary_matches_source(
|
||||
repo_dir: &Path,
|
||||
binary: &Path,
|
||||
source: &SourceState,
|
||||
) -> Result<()> {
|
||||
let report = read_binary_version_report(binary)?;
|
||||
if report.version.as_deref().unwrap_or_default().is_empty() {
|
||||
anyhow::bail!(
|
||||
"Binary smoke test for {} returned JSON without a version field",
|
||||
binary.display()
|
||||
);
|
||||
}
|
||||
validate_binary_version_matches_source_report(&report, binary, source)?;
|
||||
if !validate_dev_binary_source_metadata(binary, source)? {
|
||||
validate_dirty_binary_freshness_without_metadata(repo_dir, binary, source)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum SmokeTestReplyKind {
|
||||
Ack,
|
||||
Pong,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn smoke_test_server_request(
|
||||
stream: &mut BufReader<std::os::unix::net::UnixStream>,
|
||||
request: &serde_json::Value,
|
||||
expected_reply_kind: SmokeTestReplyKind,
|
||||
expected_reply_id: u64,
|
||||
) -> Result<()> {
|
||||
let payload = serde_json::to_string(request)? + "\n";
|
||||
stream.get_mut().write_all(payload.as_bytes())?;
|
||||
stream.get_mut().flush()?;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let bytes = stream.read_line(&mut line)?;
|
||||
if bytes == 0 {
|
||||
anyhow::bail!(
|
||||
"server closed the smoke-test socket before sending {:?} {}",
|
||||
expected_reply_kind,
|
||||
expected_reply_id
|
||||
);
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_str(line.trim()).map_err(|err| {
|
||||
anyhow::anyhow!("server smoke test returned invalid JSON line: {}", err)
|
||||
})?;
|
||||
let reply_type = value.get("type").and_then(|t| t.as_str());
|
||||
let reply_id = value.get("id").and_then(|id| id.as_u64());
|
||||
let kind_matches = match expected_reply_kind {
|
||||
SmokeTestReplyKind::Ack => reply_type == Some("ack"),
|
||||
SmokeTestReplyKind::Pong => reply_type == Some("pong"),
|
||||
};
|
||||
if kind_matches && reply_id == Some(expected_reply_id) {
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
anyhow::bail!(
|
||||
"timed out waiting for {:?} {} during server smoke test",
|
||||
expected_reply_kind,
|
||||
expected_reply_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn smoke_test_server_connect(
|
||||
path: &Path,
|
||||
) -> std::io::Result<BufReader<std::os::unix::net::UnixStream>> {
|
||||
let stream = std::os::unix::net::UnixStream::connect(path)?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
|
||||
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
|
||||
Ok(BufReader::new(stream))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn smoke_test_server_protocol(path: &Path, working_dir: &str) -> Result<()> {
|
||||
// The server handles an initial Ping on a dedicated lightweight-control
|
||||
// connection and closes it after replying, so the subscribed-client probe
|
||||
// must use a fresh socket.
|
||||
{
|
||||
let mut stream = smoke_test_server_connect(path)?;
|
||||
smoke_test_server_request(
|
||||
&mut stream,
|
||||
&serde_json::json!({
|
||||
"type": "ping",
|
||||
"id": 1
|
||||
}),
|
||||
SmokeTestReplyKind::Pong,
|
||||
1,
|
||||
)?;
|
||||
}
|
||||
|
||||
let mut stream = smoke_test_server_connect(path)?;
|
||||
smoke_test_server_request(
|
||||
&mut stream,
|
||||
&serde_json::json!({
|
||||
"type": "subscribe",
|
||||
"id": 2,
|
||||
"working_dir": working_dir
|
||||
}),
|
||||
SmokeTestReplyKind::Ack,
|
||||
2,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn smoke_test_server_binary(binary: &Path) -> Result<()> {
|
||||
use std::fs::File;
|
||||
use std::process::Stdio;
|
||||
use std::thread;
|
||||
|
||||
smoke_test_binary(binary)?;
|
||||
|
||||
let temp = tempfile::tempdir()?;
|
||||
let runtime_dir = temp.path().join("runtime");
|
||||
storage::ensure_dir(&runtime_dir)?;
|
||||
let socket_path = temp.path().join("jcode-smoke.sock");
|
||||
let stderr_path = temp.path().join("jcode-smoke.stderr.log");
|
||||
let stderr = File::create(&stderr_path)?;
|
||||
|
||||
let mut child = Command::new(binary)
|
||||
.arg("serve")
|
||||
.arg("--socket")
|
||||
.arg(&socket_path)
|
||||
.env("JCODE_NON_INTERACTIVE", "1")
|
||||
.env("JCODE_RUNTIME_DIR", &runtime_dir)
|
||||
.env("JCODE_GATEWAY_ENABLED", "0")
|
||||
.env("JCODE_TEMP_SERVER", "1")
|
||||
.env("JCODE_SERVER_OWNER_PID", std::process::id().to_string())
|
||||
.env("JCODE_TEMP_SERVER_IDLE_SECS", "300")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::from(stderr))
|
||||
.spawn()?;
|
||||
|
||||
let result = (|| -> Result<()> {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
let stderr = std::fs::read_to_string(&stderr_path).unwrap_or_default();
|
||||
anyhow::bail!(
|
||||
"server smoke test process exited early with status {:?}: {}",
|
||||
status.code(),
|
||||
stderr.trim()
|
||||
);
|
||||
}
|
||||
|
||||
match smoke_test_server_connect(&socket_path) {
|
||||
Ok(_) => {
|
||||
smoke_test_server_protocol(&socket_path, env!("CARGO_MANIFEST_DIR"))?;
|
||||
return Ok(());
|
||||
}
|
||||
Err(err)
|
||||
if matches!(
|
||||
err.kind(),
|
||||
std::io::ErrorKind::NotFound
|
||||
| std::io::ErrorKind::ConnectionRefused
|
||||
| std::io::ErrorKind::WouldBlock
|
||||
) =>
|
||||
{
|
||||
if Instant::now() >= deadline {
|
||||
let stderr = std::fs::read_to_string(&stderr_path).unwrap_or_default();
|
||||
anyhow::bail!(
|
||||
"timed out waiting for server smoke test socket {}: {}",
|
||||
socket_path.display(),
|
||||
stderr.trim()
|
||||
);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
let _ = child.kill();
|
||||
let shutdown_deadline = Instant::now() + Duration::from_secs(2);
|
||||
loop {
|
||||
if child.try_wait()?.is_some() {
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= shutdown_deadline {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn smoke_test_server_binary(binary: &Path) -> Result<()> {
|
||||
smoke_test_binary(binary)
|
||||
}
|
||||
|
||||
fn update_channel_symlink(channel: &str, version: &str) -> Result<PathBuf> {
|
||||
let channel_dir = builds_dir()?.join(channel);
|
||||
storage::ensure_dir(&channel_dir)?;
|
||||
|
||||
let link_path = channel_dir.join(binary_name());
|
||||
let target = version_binary_path(version)?;
|
||||
if !target.exists() {
|
||||
anyhow::bail!("Version binary not found at {:?}", target);
|
||||
}
|
||||
|
||||
let temp = channel_dir.join(format!(
|
||||
".{}-{}-{}",
|
||||
binary_stem(),
|
||||
channel,
|
||||
std::process::id()
|
||||
));
|
||||
crate::platform_support::atomic_symlink_swap(&target, &link_path, &temp)?;
|
||||
|
||||
Ok(link_path)
|
||||
}
|
||||
|
||||
/// Update stable symlink to point to a version and publish stable-version marker.
|
||||
pub fn update_stable_symlink(version: &str) -> Result<PathBuf> {
|
||||
let stable_link = update_channel_symlink("stable", version)?;
|
||||
std::fs::write(stable_version_file()?, version)?;
|
||||
Ok(stable_link)
|
||||
}
|
||||
|
||||
/// Update current symlink to point to a version and publish current-version marker.
|
||||
pub fn update_current_symlink(version: &str) -> Result<PathBuf> {
|
||||
let current_link = update_channel_symlink("current", version)?;
|
||||
std::fs::write(current_version_file()?, version)?;
|
||||
Ok(current_link)
|
||||
}
|
||||
|
||||
/// Update the shared server symlink to point to a version and publish the
|
||||
/// shared-server-version marker.
|
||||
pub fn update_shared_server_symlink(version: &str) -> Result<PathBuf> {
|
||||
let shared_link = update_channel_symlink("shared-server", version)?;
|
||||
std::fs::write(shared_server_version_file()?, version)?;
|
||||
Ok(shared_link)
|
||||
}
|
||||
|
||||
pub fn publish_local_current_build_for_source(
|
||||
repo_dir: &Path,
|
||||
source: &SourceState,
|
||||
) -> Result<PublishedBuild> {
|
||||
let binary = find_dev_binary(repo_dir)
|
||||
.ok_or_else(|| anyhow::anyhow!("Binary not found in target/selfdev or target/release"))?;
|
||||
if !binary.exists() {
|
||||
anyhow::bail!("Binary not found at {:?}", binary);
|
||||
}
|
||||
|
||||
validate_dev_binary_matches_source(repo_dir, &binary, source)?;
|
||||
let previous_current_version = read_current_version()?;
|
||||
let versioned_path = install_binary_at_version(&binary, &source.version_label)?;
|
||||
let installed_report = read_binary_version_report(&versioned_path)?;
|
||||
if installed_report
|
||||
.version
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.is_empty()
|
||||
{
|
||||
anyhow::bail!(
|
||||
"Binary smoke test for {} returned JSON without a version field",
|
||||
versioned_path.display()
|
||||
);
|
||||
}
|
||||
validate_binary_version_matches_source_report(&installed_report, &versioned_path, source)?;
|
||||
let current_link = update_current_symlink(&source.version_label)?;
|
||||
let launcher_link = update_launcher_symlink_to_current()?;
|
||||
|
||||
Ok(PublishedBuild {
|
||||
version: source.version_label.clone(),
|
||||
source_fingerprint: source.fingerprint.clone(),
|
||||
versioned_path,
|
||||
current_link,
|
||||
launcher_link,
|
||||
previous_current_version,
|
||||
})
|
||||
}
|
||||
|
||||
/// Install the local release binary into immutable versions and make it the active `current`
|
||||
/// build + launcher, while keeping `stable` untouched.
|
||||
pub fn publish_local_current_build(repo_dir: &std::path::Path) -> Result<PathBuf> {
|
||||
let source = current_source_state(repo_dir)?;
|
||||
Ok(publish_local_current_build_for_source(repo_dir, &source)?.versioned_path)
|
||||
}
|
||||
|
||||
/// Promote an already installed immutable version onto the shared server channel.
|
||||
pub fn promote_version_to_shared_server(version: &str) -> Result<Option<String>> {
|
||||
let previous = read_shared_server_version()?;
|
||||
update_shared_server_symlink(version)?;
|
||||
Ok(previous)
|
||||
}
|
||||
|
||||
/// Returns true when the `shared-server` channel is merely tracking the
|
||||
/// `stable` channel rather than pinned to a deliberately-promoted build (e.g. a
|
||||
/// local self-dev binary).
|
||||
///
|
||||
/// Updates only advance `current`/`stable`, so the long-lived daemon's reload
|
||||
/// target (`shared-server`) can drift behind an update. When the channel was
|
||||
/// just following stable we want updates to carry it forward automatically;
|
||||
/// when it was explicitly promoted to a self-dev build we must leave it alone
|
||||
/// so an update never silently wipes that build out from under a force reload.
|
||||
///
|
||||
/// A never-promoted (missing/empty) shared-server marker counts as "tracking":
|
||||
/// there is no deliberate build to protect, so it is safe for updates to begin
|
||||
/// populating the channel.
|
||||
pub fn shared_server_tracks_stable() -> Result<bool> {
|
||||
let shared = read_shared_server_version()?;
|
||||
let shared = shared.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||
let Some(shared) = shared else {
|
||||
return Ok(true);
|
||||
};
|
||||
let stable = read_stable_version()?;
|
||||
let stable = stable.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||
Ok(stable == Some(shared))
|
||||
}
|
||||
|
||||
/// Advance the `shared-server` channel to `version`, but only when it is
|
||||
/// currently tracking `stable` (see [`shared_server_tracks_stable`]). Returns
|
||||
/// `Ok(true)` when the channel was advanced.
|
||||
///
|
||||
/// Callers in the update path MUST invoke this *before* moving the `stable`
|
||||
/// marker, otherwise the pre-update comparison would always disagree.
|
||||
pub fn advance_shared_server_if_tracking_stable(version: &str) -> Result<bool> {
|
||||
if shared_server_tracks_stable()? {
|
||||
update_shared_server_symlink(version)?;
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of [`repair_stale_shared_server_channel`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SharedServerRepair {
|
||||
/// The `shared-server` channel was repointed at the installed `stable`
|
||||
/// release because stable was strictly newer on disk.
|
||||
Repaired {
|
||||
previous: Option<String>,
|
||||
repaired_to: String,
|
||||
},
|
||||
/// Nothing to do: shared-server is already at/newer than stable, or there is
|
||||
/// no usable stable target.
|
||||
AlreadyCurrent,
|
||||
}
|
||||
|
||||
/// Drag a *stale* `shared-server` channel forward to the installed `stable`
|
||||
/// release so a long-lived daemon can actually reload into a newer binary.
|
||||
///
|
||||
/// This is the client-side counterpart to [`advance_shared_server_if_tracking_stable`].
|
||||
/// Updates advance `stable` but only advance `shared-server` *during the install
|
||||
/// path*; a client that is already on the newest release (so `/update` is a
|
||||
/// no-op) never re-runs that install path, leaving a long-lived older daemon
|
||||
/// pinned to its old `shared-server` binary forever. A newer client that detects
|
||||
/// an older server calls this to repoint `shared-server` -> `stable` before
|
||||
/// asking the server to reload, so the forced reload has a strictly-newer target
|
||||
/// to exec into instead of re-execing the same old binary (the "current client,
|
||||
/// stale server" report).
|
||||
///
|
||||
/// Safety: we only repair when the `stable` binary is *strictly newer by mtime*
|
||||
/// than the current `shared-server` binary. That preserves a deliberately-pinned
|
||||
/// self-dev `shared-server` build whenever it is at least as fresh as stable (the
|
||||
/// case the pin exists to protect), and never downgrades the channel.
|
||||
pub fn repair_stale_shared_server_channel() -> Result<SharedServerRepair> {
|
||||
let stable_version = read_stable_version()?;
|
||||
let Some(stable_version) = stable_version
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
else {
|
||||
return Ok(SharedServerRepair::AlreadyCurrent);
|
||||
};
|
||||
|
||||
let stable_binary = stable_binary_path()?;
|
||||
if !stable_binary.exists() {
|
||||
return Ok(SharedServerRepair::AlreadyCurrent);
|
||||
}
|
||||
|
||||
// If shared-server already resolves to the same version marker, there is
|
||||
// nothing to repair.
|
||||
let previous = read_shared_server_version()?;
|
||||
if previous.as_deref().map(str::trim).filter(|s| !s.is_empty()) == Some(stable_version) {
|
||||
return Ok(SharedServerRepair::AlreadyCurrent);
|
||||
}
|
||||
if previous
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.is_some_and(|previous| !is_release_channel_marker(previous))
|
||||
{
|
||||
return Ok(SharedServerRepair::AlreadyCurrent);
|
||||
}
|
||||
|
||||
// Only repair when stable is strictly newer than the current shared-server
|
||||
// binary on disk. This never downgrades, and it preserves a self-dev pin
|
||||
// that is fresher than stable.
|
||||
let shared_binary = shared_server_binary_path()?;
|
||||
if !shared_server_binary_is_strictly_older_than(&shared_binary, &stable_binary) {
|
||||
return Ok(SharedServerRepair::AlreadyCurrent);
|
||||
}
|
||||
|
||||
update_shared_server_symlink(stable_version)?;
|
||||
Ok(SharedServerRepair::Repaired {
|
||||
previous: previous
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string),
|
||||
repaired_to: stable_version.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_release_channel_marker(marker: &str) -> bool {
|
||||
let marker = marker.trim();
|
||||
let marker = marker.strip_prefix('v').unwrap_or(marker);
|
||||
marker.starts_with("main-")
|
||||
|| marker
|
||||
.split('.')
|
||||
.all(|part| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit()))
|
||||
}
|
||||
|
||||
/// True when `shared` exists and is strictly older (by mtime) than `stable`, or
|
||||
/// when `shared` is missing entirely (nothing to protect). Any mtime
|
||||
/// uncertainty on an existing shared binary is treated as "not older" so we
|
||||
/// never repair away an unverifiable (possibly newer) pinned build.
|
||||
///
|
||||
/// Both paths are resolved through [`resolve_binary_payload`] so release
|
||||
/// installs (wrapper script + `.bin` payload) compare the payloads that
|
||||
/// actually run instead of the tiny wrapper scripts, whose mtimes carry no
|
||||
/// version information.
|
||||
fn shared_server_binary_is_strictly_older_than(
|
||||
shared: &std::path::Path,
|
||||
stable: &std::path::Path,
|
||||
) -> bool {
|
||||
let mtime = |p: &std::path::Path| {
|
||||
std::fs::metadata(resolve_binary_payload(p))
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
};
|
||||
let stable_mtime = match mtime(stable) {
|
||||
Some(m) => m,
|
||||
None => return false,
|
||||
};
|
||||
if !shared.exists() {
|
||||
// No deliberate pin on disk; safe to point the channel at stable.
|
||||
return true;
|
||||
}
|
||||
match mtime(shared) {
|
||||
Some(shared_mtime) => shared_mtime < stable_mtime,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Install release binary into immutable versions, promote it to stable, and also make it the
|
||||
/// active current/launcher build.
|
||||
pub fn install_local_release(repo_dir: &std::path::Path) -> Result<PathBuf> {
|
||||
let source = release_binary_path(repo_dir);
|
||||
if !source.exists() {
|
||||
anyhow::bail!("Binary not found at {:?}", source);
|
||||
}
|
||||
|
||||
let version = repo_build_version(repo_dir)?;
|
||||
|
||||
let versioned = install_binary_at_version(&source, &version)?;
|
||||
update_stable_symlink(&version)?;
|
||||
update_current_symlink(&version)?;
|
||||
update_shared_server_symlink(&version)?;
|
||||
update_launcher_symlink_to_current()?;
|
||||
|
||||
Ok(versioned)
|
||||
}
|
||||
|
||||
/// Copy binary to versioned location
|
||||
pub fn install_version(repo_dir: &std::path::Path, hash: &str) -> Result<PathBuf> {
|
||||
let source = release_binary_path(repo_dir);
|
||||
install_binary_at_version(&source, hash)
|
||||
}
|
||||
|
||||
/// Update canary symlink to point to a version
|
||||
pub fn update_canary_symlink(hash: &str) -> Result<()> {
|
||||
let _ = update_channel_symlink("canary", hash)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,699 @@
|
||||
use super::{
|
||||
SelfDevBuildCommand, SelfDevBuildTarget, canary_binary_path, current_binary_path,
|
||||
read_current_version, read_shared_server_version, read_stable_version,
|
||||
shared_server_binary_path, stable_binary_path,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use jcode_storage as storage;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// Get the jcode repository directory
|
||||
pub fn get_repo_dir() -> Option<PathBuf> {
|
||||
if let Ok(path) = std::env::var("JCODE_REPO_DIR") {
|
||||
let path = PathBuf::from(path);
|
||||
if is_jcode_repo(&path) {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
|
||||
// First try: compile-time directory
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let path = PathBuf::from(manifest_dir);
|
||||
if let Some(repo) = find_repo_in_ancestors(&path) {
|
||||
return Some(repo);
|
||||
}
|
||||
|
||||
// Fallback: check relative to executable
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
// Assume structure: repo/target/<profile>/<binary> (platform-specific executable name)
|
||||
if let Some(repo) = exe
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.and_then(|p| p.parent())
|
||||
&& is_jcode_repo(repo)
|
||||
{
|
||||
return Some(repo.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback: search upward from current working directory.
|
||||
// This matters for self-dev sessions launched from the repo but running
|
||||
// from an installed canary/stable binary whose current_exe() is outside
|
||||
// the source tree.
|
||||
if let Ok(cwd) = std::env::current_dir()
|
||||
&& let Some(repo) = find_repo_in_ancestors(&cwd)
|
||||
{
|
||||
return Some(repo);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn find_repo_in_ancestors(start: &Path) -> Option<PathBuf> {
|
||||
for dir in start.ancestors() {
|
||||
if is_jcode_repo(dir) {
|
||||
return Some(dir.to_path_buf());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn binary_stem() -> &'static str {
|
||||
"jcode"
|
||||
}
|
||||
|
||||
pub fn binary_name() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"jcode.exe"
|
||||
} else {
|
||||
binary_stem()
|
||||
}
|
||||
}
|
||||
|
||||
pub const SELFDEV_CARGO_PROFILE: &str = "selfdev";
|
||||
|
||||
/// Resolve a channel/launcher binary path to the file that actually runs.
|
||||
///
|
||||
/// Release archives install a tiny `jcode` wrapper script alongside the real
|
||||
/// `jcode-<platform>.bin` payload (the wrapper sets `LD_LIBRARY_PATH` and execs
|
||||
/// the payload). Channel symlinks point at the wrapper and reload/exec must
|
||||
/// keep using the wrapper, but the *running process* (`current_exe()`) is the
|
||||
/// payload. Any "is the candidate the same/newer binary than the running one?"
|
||||
/// comparison must therefore compare payloads. Comparing the wrapper against
|
||||
/// the payload compares two different files with unrelated mtimes, which made
|
||||
/// `server_has_newer_binary()` report a phantom update forever and locked
|
||||
/// post-`/update` sessions into an infinite reload loop.
|
||||
///
|
||||
/// Returns the canonicalized payload path when `path` resolves to a wrapper
|
||||
/// script with a unique sibling `<stem>-*.bin` payload; otherwise returns the
|
||||
/// canonicalized input path.
|
||||
pub fn resolve_binary_payload(path: &Path) -> PathBuf {
|
||||
let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
||||
wrapper_payload_sibling(&canonical).unwrap_or(canonical)
|
||||
}
|
||||
|
||||
fn wrapper_payload_sibling(path: &Path) -> Option<PathBuf> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
// Wrapper scripts are a few hundred bytes; real binaries are tens of MB.
|
||||
// The size gate keeps us from reading a large binary just to check "#!".
|
||||
if !meta.is_file() || meta.len() > 4096 {
|
||||
return None;
|
||||
}
|
||||
let mut prefix = [0u8; 2];
|
||||
{
|
||||
use std::io::Read;
|
||||
let mut file = std::fs::File::open(path).ok()?;
|
||||
file.read_exact(&mut prefix).ok()?;
|
||||
}
|
||||
if &prefix != b"#!" {
|
||||
return None;
|
||||
}
|
||||
let dir = path.parent()?;
|
||||
let stem = path.file_stem()?.to_str()?;
|
||||
let payload_prefix = format!("{stem}-");
|
||||
let mut payload: Option<PathBuf> = None;
|
||||
for entry in std::fs::read_dir(dir).ok()? {
|
||||
let entry = entry.ok()?;
|
||||
let name = entry.file_name();
|
||||
let name = name.to_str()?;
|
||||
if name.starts_with(&payload_prefix) && name.ends_with(".bin") {
|
||||
if payload.is_some() {
|
||||
// Ambiguous: more than one payload candidate. Refuse to guess.
|
||||
return None;
|
||||
}
|
||||
payload = Some(entry.path());
|
||||
}
|
||||
}
|
||||
payload.filter(|p| p.is_file())
|
||||
}
|
||||
|
||||
fn profile_binary_path(repo_dir: &Path, profile: &str) -> PathBuf {
|
||||
repo_dir.join("target").join(profile).join(binary_name())
|
||||
}
|
||||
|
||||
pub fn release_binary_path(repo_dir: &Path) -> PathBuf {
|
||||
profile_binary_path(repo_dir, "release")
|
||||
}
|
||||
|
||||
pub fn selfdev_binary_path(repo_dir: &Path) -> PathBuf {
|
||||
profile_binary_path(repo_dir, SELFDEV_CARGO_PROFILE)
|
||||
}
|
||||
|
||||
fn binary_mtime(path: &Path) -> Option<SystemTime> {
|
||||
std::fs::metadata(path)
|
||||
.ok()
|
||||
.and_then(|meta| meta.modified().ok())
|
||||
}
|
||||
|
||||
fn newest_existing_binary(
|
||||
candidates: Vec<(PathBuf, &'static str)>,
|
||||
) -> Option<(PathBuf, &'static str)> {
|
||||
candidates
|
||||
.into_iter()
|
||||
.filter(|(path, _)| path.exists())
|
||||
.max_by_key(|(path, _)| binary_mtime(path))
|
||||
}
|
||||
|
||||
fn existing_binary(path: Result<PathBuf>, label: &'static str) -> Option<(PathBuf, &'static str)> {
|
||||
path.ok()
|
||||
.filter(|path| path.exists())
|
||||
.map(|path| (path, label))
|
||||
}
|
||||
|
||||
pub fn selfdev_build_command(repo_dir: &Path) -> SelfDevBuildCommand {
|
||||
selfdev_build_command_for_target(repo_dir, SelfDevBuildTarget::Auto)
|
||||
}
|
||||
|
||||
pub fn selfdev_build_command_for_target(
|
||||
repo_dir: &Path,
|
||||
target: SelfDevBuildTarget,
|
||||
) -> SelfDevBuildCommand {
|
||||
let target = match target {
|
||||
SelfDevBuildTarget::Auto => infer_selfdev_build_target(repo_dir),
|
||||
explicit => explicit,
|
||||
};
|
||||
let specs = match target {
|
||||
SelfDevBuildTarget::Tui => vec![("jcode", "jcode")],
|
||||
SelfDevBuildTarget::Desktop => vec![("jcode-desktop", "jcode-desktop")],
|
||||
SelfDevBuildTarget::All | SelfDevBuildTarget::Auto => {
|
||||
vec![("jcode", "jcode"), ("jcode-desktop", "jcode-desktop")]
|
||||
}
|
||||
};
|
||||
let wrapper = repo_dir.join("scripts").join("dev_cargo.sh");
|
||||
if wrapper.is_file() {
|
||||
let script = wrapper.to_string_lossy();
|
||||
let command = specs
|
||||
.iter()
|
||||
.map(|(package, binary)| {
|
||||
format!(
|
||||
"{} build --profile {} -p {} --bin {}",
|
||||
shell_escape(&script),
|
||||
SELFDEV_CARGO_PROFILE,
|
||||
package,
|
||||
binary
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" && ");
|
||||
return SelfDevBuildCommand {
|
||||
program: "bash".to_string(),
|
||||
args: vec!["-lc".to_string(), command],
|
||||
display: display_build_command("scripts/dev_cargo.sh", &specs),
|
||||
};
|
||||
}
|
||||
|
||||
let command = display_build_command("cargo", &specs);
|
||||
SelfDevBuildCommand {
|
||||
program: "bash".to_string(),
|
||||
args: vec!["-lc".to_string(), command.clone()],
|
||||
display: command,
|
||||
}
|
||||
}
|
||||
|
||||
fn display_build_command(program: &str, specs: &[(&str, &str)]) -> String {
|
||||
specs
|
||||
.iter()
|
||||
.map(|(package, binary)| {
|
||||
format!(
|
||||
"{} build --profile {} -p {} --bin {}",
|
||||
program, SELFDEV_CARGO_PROFILE, package, binary
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" && ")
|
||||
}
|
||||
|
||||
fn infer_selfdev_build_target(repo_dir: &Path) -> SelfDevBuildTarget {
|
||||
let output = Command::new("git")
|
||||
.args(["status", "--porcelain=v1", "--untracked-files=all"])
|
||||
.current_dir(repo_dir)
|
||||
.output();
|
||||
let Ok(output) = output else {
|
||||
return SelfDevBuildTarget::Tui;
|
||||
};
|
||||
if !output.status.success() {
|
||||
return SelfDevBuildTarget::Tui;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
let mut desktop = false;
|
||||
let mut other = false;
|
||||
for line in text.lines() {
|
||||
let path = line
|
||||
.get(3..)
|
||||
.unwrap_or(line)
|
||||
.trim()
|
||||
.rsplit_once(" -> ")
|
||||
.map(|(_, new_path)| new_path)
|
||||
.unwrap_or_else(|| line.get(3..).unwrap_or(line).trim());
|
||||
if path == "Cargo.toml" || path == "Cargo.lock" || path.starts_with(".cargo/") {
|
||||
desktop = true;
|
||||
other = true;
|
||||
} else if path.starts_with("crates/jcode-desktop/") {
|
||||
desktop = true;
|
||||
} else if !path.is_empty() {
|
||||
other = true;
|
||||
}
|
||||
}
|
||||
match (desktop, other) {
|
||||
(true, false) => SelfDevBuildTarget::Desktop,
|
||||
(false, true) => SelfDevBuildTarget::Tui,
|
||||
(true, true) => SelfDevBuildTarget::All,
|
||||
(false, false) => SelfDevBuildTarget::Tui,
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_escape(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "'\\''"))
|
||||
}
|
||||
|
||||
pub fn run_selfdev_build(repo_dir: &Path) -> Result<SelfDevBuildCommand> {
|
||||
let source = super::current_source_state(repo_dir)?;
|
||||
let build = selfdev_build_command(repo_dir);
|
||||
let status = Command::new(&build.program)
|
||||
.args(&build.args)
|
||||
.current_dir(repo_dir)
|
||||
.status()?;
|
||||
|
||||
if !status.success() {
|
||||
anyhow::bail!("Build failed: {}", build.display);
|
||||
}
|
||||
|
||||
let source_after_build = super::ensure_source_state_matches(repo_dir, &source)?;
|
||||
super::write_current_dev_binary_source_metadata(repo_dir, &source_after_build)?;
|
||||
|
||||
Ok(build)
|
||||
}
|
||||
|
||||
pub fn current_binary_built_at() -> Option<DateTime<Utc>> {
|
||||
let modified: SystemTime = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|path| std::fs::metadata(path).ok())
|
||||
.and_then(|meta| meta.modified().ok())?;
|
||||
Some(DateTime::<Utc>::from(modified))
|
||||
}
|
||||
|
||||
pub fn current_binary_build_time_string() -> Option<String> {
|
||||
current_binary_built_at().map(|dt| dt.format("%Y-%m-%d %H:%M:%S %z").to_string())
|
||||
}
|
||||
|
||||
/// Find the best development binary in the repo.
|
||||
/// Prefers the newest local self-dev or release binary.
|
||||
pub fn find_dev_binary(repo_dir: &Path) -> Option<PathBuf> {
|
||||
newest_existing_binary(vec![
|
||||
(selfdev_binary_path(repo_dir), "repo-selfdev"),
|
||||
(release_binary_path(repo_dir), "repo-release"),
|
||||
])
|
||||
.map(|(path, _)| path)
|
||||
}
|
||||
|
||||
fn home_dir() -> Result<PathBuf> {
|
||||
std::env::var("HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|_| std::env::var("USERPROFILE").map(PathBuf::from))
|
||||
.map_err(|_| anyhow::anyhow!("HOME/USERPROFILE not set"))
|
||||
}
|
||||
|
||||
fn non_empty_env_path(name: &str) -> Option<PathBuf> {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
/// Directory for the single launcher path users execute from PATH.
|
||||
///
|
||||
/// Defaults to `~/.local/bin` on Unix, `%LOCALAPPDATA%\jcode\bin` on Windows.
|
||||
/// Overridable with `JCODE_INSTALL_DIR`.
|
||||
pub fn launcher_dir() -> Result<PathBuf> {
|
||||
if let Some(custom) = non_empty_env_path("JCODE_INSTALL_DIR") {
|
||||
return Ok(custom);
|
||||
}
|
||||
|
||||
if let Some(sandbox_home) = non_empty_env_path("JCODE_HOME") {
|
||||
return Ok(sandbox_home.join("bin"));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Ok(local) = std::env::var("LOCALAPPDATA") {
|
||||
return Ok(PathBuf::from(local).join("jcode").join("bin"));
|
||||
}
|
||||
Ok(home_dir()?
|
||||
.join("AppData")
|
||||
.join("Local")
|
||||
.join("jcode")
|
||||
.join("bin"))
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
Ok(home_dir()?.join(".local").join("bin"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Path to the launcher binary (`~/.local/bin/jcode` by default).
|
||||
pub fn launcher_binary_path() -> Result<PathBuf> {
|
||||
Ok(launcher_dir()?.join(binary_name()))
|
||||
}
|
||||
|
||||
fn update_launcher_symlink(target: &Path) -> Result<PathBuf> {
|
||||
let launcher = launcher_binary_path()?;
|
||||
|
||||
if let Some(parent) = launcher.parent() {
|
||||
storage::ensure_dir(parent)?;
|
||||
}
|
||||
|
||||
let temp = launcher
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join(format!(
|
||||
".{}-launcher-{}",
|
||||
binary_stem(),
|
||||
std::process::id()
|
||||
));
|
||||
|
||||
crate::platform_support::atomic_symlink_swap(target, &launcher, &temp)?;
|
||||
Ok(launcher)
|
||||
}
|
||||
|
||||
/// Update launcher path to point at the current channel binary.
|
||||
pub fn update_launcher_symlink_to_current() -> Result<PathBuf> {
|
||||
let current = current_binary_path()?;
|
||||
update_launcher_symlink(¤t)
|
||||
}
|
||||
|
||||
/// Update launcher path to point at the stable channel binary.
|
||||
pub fn update_launcher_symlink_to_stable() -> Result<PathBuf> {
|
||||
let stable = stable_binary_path()?;
|
||||
update_launcher_symlink(&stable)
|
||||
}
|
||||
|
||||
/// Resolve which client binary should be considered for launches, updates, and reloads.
|
||||
///
|
||||
/// Order matters:
|
||||
/// - Prefer the published `current` channel first (active local build)
|
||||
/// - Self-dev sessions can fall back to an unpublished repo build from `target/selfdev` or `target/release`
|
||||
/// - Then the self-dev canary channel
|
||||
/// - Then launcher path
|
||||
/// - Then stable channel path
|
||||
/// - Finally currently running executable
|
||||
pub fn client_update_candidate(is_selfdev_session: bool) -> Option<(PathBuf, &'static str)> {
|
||||
if let Some(current) = existing_binary(current_binary_path(), "current") {
|
||||
return Some(current);
|
||||
}
|
||||
|
||||
if is_selfdev_session {
|
||||
if let Some(repo_dir) = get_repo_dir()
|
||||
&& let Some(dev) = find_dev_binary(&repo_dir)
|
||||
&& dev.exists()
|
||||
{
|
||||
return Some((dev, "dev"));
|
||||
}
|
||||
if let Some(canary) = existing_binary(canary_binary_path(), "canary") {
|
||||
return Some(canary);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(launcher) = existing_binary(launcher_binary_path(), "launcher") {
|
||||
return Some(launcher);
|
||||
}
|
||||
|
||||
if let Some(stable) = existing_binary(stable_binary_path(), "stable") {
|
||||
return Some(stable);
|
||||
}
|
||||
|
||||
std::env::current_exe().ok().map(|exe| (exe, "current"))
|
||||
}
|
||||
|
||||
/// Resolve the binary that the shared daemon should spawn or reload into.
|
||||
///
|
||||
/// This intentionally does not follow the fast-moving `current` channel. The
|
||||
/// shared server should only run binaries that were explicitly promoted onto the
|
||||
/// shared-server channel (or stable as fallback), so local dirty self-dev builds
|
||||
/// stop taking out every client by accident.
|
||||
pub fn shared_server_update_candidate(is_selfdev_session: bool) -> Option<(PathBuf, &'static str)> {
|
||||
let shared_server = existing_binary(shared_server_binary_path(), "shared-server");
|
||||
if is_selfdev_session {
|
||||
if let Some(shared_server) = shared_server {
|
||||
return Some(shared_server);
|
||||
}
|
||||
} else if let Some(shared_server) = shared_server
|
||||
&& shared_server_channel_is_current_enough()
|
||||
{
|
||||
return Some(shared_server);
|
||||
}
|
||||
|
||||
if let Some(stable) = existing_binary(stable_binary_path(), "stable") {
|
||||
return Some(stable);
|
||||
}
|
||||
|
||||
std::env::current_exe().ok().map(|exe| (exe, "current"))
|
||||
}
|
||||
|
||||
fn shared_server_channel_is_current_enough() -> bool {
|
||||
let shared = read_shared_server_version().ok().flatten();
|
||||
let Some(shared) = shared
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let stable = read_stable_version().ok().flatten();
|
||||
if stable
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some_and(|stable| stable == shared)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
let current = read_current_version().ok().flatten();
|
||||
current
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some_and(|current| current == shared)
|
||||
}
|
||||
|
||||
fn normalize_version_marker(value: &str) -> String {
|
||||
let value = value.trim();
|
||||
let value = value.strip_prefix('v').unwrap_or(value);
|
||||
value
|
||||
.split([' ', '(', ')'])
|
||||
.next()
|
||||
.unwrap_or(value)
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn version_matches_installed_channel(version: &str, git_hash: &str) -> bool {
|
||||
let version = normalize_version_marker(version);
|
||||
let git_hash = git_hash.trim();
|
||||
let mut saw_marker = false;
|
||||
for marker in [read_stable_version(), read_current_version()] {
|
||||
let Some(marker) = marker.ok().flatten() else {
|
||||
continue;
|
||||
};
|
||||
let marker_trimmed = marker.trim();
|
||||
if marker_trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
saw_marker = true;
|
||||
if normalize_version_marker(marker_trimmed) == version {
|
||||
return true;
|
||||
}
|
||||
if !git_hash.is_empty()
|
||||
&& git_hash != "unknown"
|
||||
&& (marker_trimmed == git_hash || marker_trimmed.starts_with(git_hash))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
!saw_marker
|
||||
}
|
||||
|
||||
/// Resolve the best binary to use for `/reload`.
|
||||
///
|
||||
/// This mostly follows `client_update_candidate`, but if a freshly built repo
|
||||
/// release binary exists and is newer than the selected channel binary, prefer
|
||||
/// that so local rebuilds can reload correctly even if publishing the build
|
||||
/// failed.
|
||||
pub fn preferred_reload_candidate(is_selfdev_session: bool) -> Option<(PathBuf, &'static str)> {
|
||||
let candidate = client_update_candidate(is_selfdev_session);
|
||||
|
||||
let repo_binary = get_repo_dir().and_then(|repo_dir| {
|
||||
if is_selfdev_session {
|
||||
newest_existing_binary(vec![
|
||||
(selfdev_binary_path(&repo_dir), "repo-selfdev"),
|
||||
(release_binary_path(&repo_dir), "repo-release"),
|
||||
])
|
||||
} else {
|
||||
newest_existing_binary(vec![(release_binary_path(&repo_dir), "repo-release")])
|
||||
}
|
||||
});
|
||||
|
||||
let repo_is_newer = |repo: &Path, current: &Path| {
|
||||
// `current` may be a channel symlink to a release wrapper script;
|
||||
// compare the payload that actually runs, not the wrapper.
|
||||
match (
|
||||
binary_mtime(repo),
|
||||
binary_mtime(&resolve_binary_payload(current)),
|
||||
) {
|
||||
(Some(repo), Some(current)) => repo > current,
|
||||
(Some(_), None) => true,
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
|
||||
match (repo_binary, candidate) {
|
||||
(Some((repo, label)), Some((current, _))) if repo_is_newer(&repo, ¤t) => {
|
||||
Some((repo, label))
|
||||
}
|
||||
(Some((repo, label)), None) => Some((repo, label)),
|
||||
(_, Some(candidate)) => Some(candidate),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a directory is the jcode repository
|
||||
pub fn is_jcode_repo(dir: &Path) -> bool {
|
||||
// Check for Cargo.toml with name = "jcode"
|
||||
let cargo_toml = dir.join("Cargo.toml");
|
||||
if !cargo_toml.exists() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for a .git directory or gitdir file (worktrees use a file).
|
||||
if !dir.join(".git").exists() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read Cargo.toml and check package name
|
||||
if let Ok(content) = std::fs::read_to_string(&cargo_toml)
|
||||
&& content.contains("name = \"jcode\"")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn repo_fixture(git_file: bool) -> tempfile::TempDir {
|
||||
let temp = tempfile::TempDir::new().expect("temp repo");
|
||||
if git_file {
|
||||
std::fs::write(temp.path().join(".git"), "gitdir: /tmp/jcode-test-git\n")
|
||||
.expect("git file");
|
||||
} else {
|
||||
std::fs::create_dir_all(temp.path().join(".git")).expect("git dir");
|
||||
}
|
||||
std::fs::write(
|
||||
temp.path().join("Cargo.toml"),
|
||||
"[package]\nname = \"jcode\"\nversion = \"0.1.0\"\n",
|
||||
)
|
||||
.expect("Cargo.toml");
|
||||
temp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_repo_in_ancestors_finds_workspace_from_crate_dir() {
|
||||
let repo = repo_fixture(false);
|
||||
let crate_dir = repo.path().join("crates").join("jcode-build-support");
|
||||
std::fs::create_dir_all(&crate_dir).expect("crate dir");
|
||||
|
||||
assert_eq!(
|
||||
find_repo_in_ancestors(&crate_dir).as_deref(),
|
||||
Some(repo.path())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_jcode_repo_accepts_git_file_for_worktree() {
|
||||
let repo = repo_fixture(true);
|
||||
assert!(is_jcode_repo(repo.path()));
|
||||
}
|
||||
|
||||
/// Build a release-style install dir: `jcode` wrapper script + payload.
|
||||
fn release_install_fixture() -> (tempfile::TempDir, PathBuf, PathBuf) {
|
||||
let temp = tempfile::TempDir::new().expect("temp install");
|
||||
let wrapper = temp.path().join("jcode");
|
||||
let payload = temp.path().join("jcode-linux-x86_64.bin");
|
||||
std::fs::write(
|
||||
&wrapper,
|
||||
"#!/usr/bin/env sh\nexec ./jcode-linux-x86_64.bin \"$@\"\n",
|
||||
)
|
||||
.expect("wrapper");
|
||||
std::fs::write(&payload, vec![0x7fu8; 64]).expect("payload");
|
||||
(temp, wrapper, payload)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_binary_payload_maps_wrapper_to_payload() {
|
||||
let (_temp, wrapper, payload) = release_install_fixture();
|
||||
assert_eq!(
|
||||
resolve_binary_payload(&wrapper),
|
||||
std::fs::canonicalize(&payload).expect("canonical payload")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_binary_payload_follows_channel_symlink_to_wrapper() {
|
||||
// The real layout: builds/<channel>/jcode -> versions/<v>/jcode (wrapper).
|
||||
let (temp, wrapper, payload) = release_install_fixture();
|
||||
let channel_dir = temp.path().join("channel");
|
||||
std::fs::create_dir_all(&channel_dir).expect("channel dir");
|
||||
let link = channel_dir.join("jcode");
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(&wrapper, &link).expect("symlink");
|
||||
#[cfg(not(unix))]
|
||||
std::fs::copy(&wrapper, &link).map(|_| ()).expect("copy");
|
||||
assert_eq!(
|
||||
resolve_binary_payload(&link),
|
||||
std::fs::canonicalize(&payload).expect("canonical payload")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_binary_payload_keeps_real_binary() {
|
||||
// A normal (non-wrapper) binary resolves to itself even with a sibling
|
||||
// `.bin` file present: it is too large / not a script.
|
||||
let temp = tempfile::TempDir::new().expect("temp install");
|
||||
let binary = temp.path().join("jcode");
|
||||
std::fs::write(&binary, vec![0x7fu8; 8192]).expect("binary");
|
||||
std::fs::write(temp.path().join("jcode-linux-x86_64.bin"), [0u8; 8]).expect("bin");
|
||||
assert_eq!(
|
||||
resolve_binary_payload(&binary),
|
||||
std::fs::canonicalize(&binary).expect("canonical binary")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_binary_payload_keeps_small_script_without_payload() {
|
||||
let temp = tempfile::TempDir::new().expect("temp install");
|
||||
let script = temp.path().join("jcode");
|
||||
std::fs::write(&script, "#!/usr/bin/env sh\nexec true\n").expect("script");
|
||||
assert_eq!(
|
||||
resolve_binary_payload(&script),
|
||||
std::fs::canonicalize(&script).expect("canonical script")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_binary_payload_refuses_ambiguous_payloads() {
|
||||
let (temp, wrapper, _payload) = release_install_fixture();
|
||||
std::fs::write(temp.path().join("jcode-macos-aarch64.bin"), [0u8; 8]).expect("second bin");
|
||||
assert_eq!(
|
||||
resolve_binary_payload(&wrapper),
|
||||
std::fs::canonicalize(&wrapper).expect("canonical wrapper")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
use std::path::Path;
|
||||
|
||||
/// Set file permissions to owner read/write/execute (0o755).
|
||||
/// No-op on Windows (executability is determined by file extension).
|
||||
pub fn set_permissions_executable(path: &Path) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = std::fs::Permissions::from_mode(0o755);
|
||||
std::fs::set_permissions(path, perms)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = path;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically swap a symlink by creating a temp symlink and renaming.
|
||||
///
|
||||
/// On Unix: creates temp symlink, then renames over target (atomic).
|
||||
/// On Windows: removes target, copies source (not atomic, but best effort).
|
||||
pub fn atomic_symlink_swap(src: &Path, dst: &Path, temp: &Path) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let _ = std::fs::remove_file(temp);
|
||||
std::os::unix::fs::symlink(src, temp)?;
|
||||
std::fs::rename(temp, dst)?;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = std::fs::remove_file(temp);
|
||||
let _ = std::fs::remove_file(dst);
|
||||
std::fs::copy(src, dst).map(|_| ())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
use super::{BuildInfo, SourceState};
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
const FNV_OFFSET_BASIS_64: u64 = 0xcbf29ce484222325;
|
||||
const FNV_PRIME_64: u64 = 0x100000001b3;
|
||||
|
||||
fn stable_hash_update(state: &mut u64, bytes: &[u8]) {
|
||||
for byte in bytes {
|
||||
*state ^= u64::from(*byte);
|
||||
*state = state.wrapping_mul(FNV_PRIME_64);
|
||||
}
|
||||
}
|
||||
|
||||
fn stable_hash_str(state: &mut u64, value: &str) {
|
||||
stable_hash_update(state, value.as_bytes());
|
||||
}
|
||||
|
||||
fn stable_hash_hex(bytes: &[u8]) -> String {
|
||||
let mut state = FNV_OFFSET_BASIS_64;
|
||||
stable_hash_update(&mut state, bytes);
|
||||
format!("{state:016x}")
|
||||
}
|
||||
|
||||
fn canonicalize_or_self(path: &Path) -> PathBuf {
|
||||
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
|
||||
fn hash_path_scope(path: &Path) -> String {
|
||||
stable_hash_hex(canonicalize_or_self(path).to_string_lossy().as_bytes())
|
||||
}
|
||||
|
||||
fn git_output_bytes(repo_dir: &Path, args: &[&str]) -> Result<Vec<u8>> {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(repo_dir)
|
||||
.output()?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"git {} failed with status {:?}",
|
||||
args.join(" "),
|
||||
output.status.code()
|
||||
);
|
||||
}
|
||||
Ok(output.stdout)
|
||||
}
|
||||
|
||||
fn git_common_dir(repo_dir: &Path) -> Result<PathBuf> {
|
||||
let output = git_output_bytes(repo_dir, &["rev-parse", "--git-common-dir"])?;
|
||||
let raw = String::from_utf8_lossy(&output).trim().to_string();
|
||||
if raw.is_empty() {
|
||||
anyhow::bail!("git rev-parse --git-common-dir returned an empty path");
|
||||
}
|
||||
let path = PathBuf::from(raw);
|
||||
let absolute = if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
repo_dir.join(path)
|
||||
};
|
||||
Ok(canonicalize_or_self(&absolute))
|
||||
}
|
||||
|
||||
pub fn repo_scope_key(repo_dir: &Path) -> Result<String> {
|
||||
Ok(hash_path_scope(&git_common_dir(repo_dir)?))
|
||||
}
|
||||
|
||||
pub fn worktree_scope_key(repo_dir: &Path) -> Result<String> {
|
||||
Ok(hash_path_scope(repo_dir))
|
||||
}
|
||||
|
||||
fn append_untracked_file_fingerprint(state: &mut u64, repo_dir: &Path, relative: &str) {
|
||||
stable_hash_str(state, relative);
|
||||
let path = repo_dir.join(relative);
|
||||
match std::fs::metadata(&path) {
|
||||
Ok(meta) if meta.is_file() => {
|
||||
stable_hash_update(state, &meta.len().to_le_bytes());
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => stable_hash_update(state, &bytes),
|
||||
Err(err) => stable_hash_str(state, &format!("read-error:{err}")),
|
||||
}
|
||||
}
|
||||
Ok(meta) => {
|
||||
stable_hash_str(state, if meta.is_dir() { "dir" } else { "other" });
|
||||
}
|
||||
Err(err) => stable_hash_str(state, &format!("missing:{err}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_source_state(repo_dir: &Path) -> Result<SourceState> {
|
||||
let short_hash = current_git_hash(repo_dir)?;
|
||||
let full_hash = current_git_hash_full(repo_dir)?;
|
||||
let status = git_output_bytes(
|
||||
repo_dir,
|
||||
&["status", "--porcelain=v1", "-z", "--untracked-files=all"],
|
||||
)?;
|
||||
let diff = git_output_bytes(repo_dir, &["diff", "--binary", "HEAD"])?;
|
||||
let untracked = git_output_bytes(
|
||||
repo_dir,
|
||||
&["ls-files", "--others", "--exclude-standard", "-z"],
|
||||
)?;
|
||||
|
||||
let dirty = !status.is_empty();
|
||||
let changed_paths = status
|
||||
.split(|byte| *byte == 0)
|
||||
.filter(|entry| !entry.is_empty())
|
||||
.count();
|
||||
|
||||
let mut state = FNV_OFFSET_BASIS_64;
|
||||
stable_hash_str(&mut state, &full_hash);
|
||||
stable_hash_update(&mut state, &status);
|
||||
stable_hash_update(&mut state, &diff);
|
||||
for path in untracked
|
||||
.split(|byte| *byte == 0)
|
||||
.filter(|entry| !entry.is_empty())
|
||||
{
|
||||
let relative = String::from_utf8_lossy(path);
|
||||
append_untracked_file_fingerprint(&mut state, repo_dir, &relative);
|
||||
}
|
||||
let fingerprint = format!("{state:016x}");
|
||||
let version_label = if dirty {
|
||||
format!("{}-dirty-{}", short_hash, &fingerprint[..12])
|
||||
} else {
|
||||
short_hash.clone()
|
||||
};
|
||||
|
||||
Ok(SourceState {
|
||||
repo_scope: repo_scope_key(repo_dir)?,
|
||||
worktree_scope: worktree_scope_key(repo_dir)?,
|
||||
short_hash,
|
||||
full_hash,
|
||||
dirty,
|
||||
fingerprint,
|
||||
version_label,
|
||||
changed_paths,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ensure_source_state_matches(repo_dir: &Path, expected: &SourceState) -> Result<SourceState> {
|
||||
let current = current_source_state(repo_dir)?;
|
||||
if current.fingerprint != expected.fingerprint {
|
||||
anyhow::bail!(
|
||||
"Source tree drift detected while waiting/building (expected {}, now {}). Refusing to publish or attach this build to the original request.",
|
||||
expected.fingerprint,
|
||||
current.fingerprint
|
||||
);
|
||||
}
|
||||
Ok(current)
|
||||
}
|
||||
|
||||
pub fn repo_build_version(repo_dir: &Path) -> Result<String> {
|
||||
Ok(current_source_state(repo_dir)?.version_label)
|
||||
}
|
||||
|
||||
/// Get the current git hash
|
||||
pub fn current_git_hash(repo_dir: &Path) -> Result<String> {
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "--short", "HEAD"])
|
||||
.current_dir(repo_dir)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!("Failed to get git hash");
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// Get the full git hash
|
||||
pub fn current_git_hash_full(repo_dir: &Path) -> Result<String> {
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.current_dir(repo_dir)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!("Failed to get git hash");
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// Get the git diff for uncommitted changes
|
||||
pub fn current_git_diff(repo_dir: &Path) -> Result<String> {
|
||||
let output = Command::new("git")
|
||||
.args(["diff", "HEAD"])
|
||||
.current_dir(repo_dir)
|
||||
.output()?;
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
|
||||
/// Check if working tree is dirty
|
||||
pub fn is_working_tree_dirty(repo_dir: &Path) -> Result<bool> {
|
||||
let output = Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(repo_dir)
|
||||
.output()?;
|
||||
|
||||
Ok(!output.stdout.is_empty())
|
||||
}
|
||||
|
||||
/// Get commit message for a hash
|
||||
pub fn get_commit_message(repo_dir: &Path, hash: &str) -> Result<String> {
|
||||
let output = Command::new("git")
|
||||
.args(["log", "-1", "--format=%s", hash])
|
||||
.current_dir(repo_dir)
|
||||
.output()?;
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// Build info for current state
|
||||
pub fn current_build_info(repo_dir: &Path) -> Result<BuildInfo> {
|
||||
let source = current_source_state(repo_dir)?;
|
||||
let commit_message = get_commit_message(repo_dir, &source.short_hash).ok();
|
||||
|
||||
Ok(BuildInfo {
|
||||
hash: source.short_hash,
|
||||
full_hash: source.full_hash,
|
||||
built_at: Utc::now(),
|
||||
commit_message,
|
||||
dirty: source.dirty,
|
||||
source_fingerprint: Some(source.fingerprint),
|
||||
version_label: Some(source.version_label),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
use super::{MigrationContext, binary_name};
|
||||
use anyhow::Result;
|
||||
use jcode_storage as storage;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Get path to builds directory
|
||||
pub fn builds_dir() -> Result<PathBuf> {
|
||||
let base = storage::jcode_dir()?;
|
||||
let dir = base.join("builds");
|
||||
storage::ensure_dir(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Get path to build manifest
|
||||
pub fn manifest_path() -> Result<PathBuf> {
|
||||
Ok(builds_dir()?.join("manifest.json"))
|
||||
}
|
||||
|
||||
/// Get path to a specific version's binary
|
||||
pub fn version_binary_path(hash: &str) -> Result<PathBuf> {
|
||||
Ok(builds_dir()?
|
||||
.join("versions")
|
||||
.join(hash)
|
||||
.join(binary_name()))
|
||||
}
|
||||
|
||||
/// Get path to stable symlink
|
||||
pub fn stable_binary_path() -> Result<PathBuf> {
|
||||
Ok(builds_dir()?.join("stable").join(binary_name()))
|
||||
}
|
||||
|
||||
/// Get path to current symlink (active local build channel)
|
||||
pub fn current_binary_path() -> Result<PathBuf> {
|
||||
Ok(builds_dir()?.join("current").join(binary_name()))
|
||||
}
|
||||
|
||||
/// Get path to the shared server symlink (approved daemon channel).
|
||||
pub fn shared_server_binary_path() -> Result<PathBuf> {
|
||||
Ok(builds_dir()?.join("shared-server").join(binary_name()))
|
||||
}
|
||||
|
||||
/// Get path to canary binary
|
||||
pub fn canary_binary_path() -> Result<PathBuf> {
|
||||
Ok(builds_dir()?.join("canary").join(binary_name()))
|
||||
}
|
||||
|
||||
/// Get path to migration context file
|
||||
pub fn migration_context_path(session_id: &str) -> Result<PathBuf> {
|
||||
Ok(builds_dir()?
|
||||
.join("migrations")
|
||||
.join(format!("{}.json", session_id)))
|
||||
}
|
||||
|
||||
/// Get path to stable version file (watched by other sessions)
|
||||
pub fn stable_version_file() -> Result<PathBuf> {
|
||||
Ok(builds_dir()?.join("stable-version"))
|
||||
}
|
||||
|
||||
/// Get path to current version file (active local build marker).
|
||||
pub fn current_version_file() -> Result<PathBuf> {
|
||||
Ok(builds_dir()?.join("current-version"))
|
||||
}
|
||||
|
||||
/// Get path to the shared server version file (approved daemon marker).
|
||||
pub fn shared_server_version_file() -> Result<PathBuf> {
|
||||
Ok(builds_dir()?.join("shared-server-version"))
|
||||
}
|
||||
|
||||
/// Save migration context before switching to canary
|
||||
pub fn save_migration_context(ctx: &MigrationContext) -> Result<()> {
|
||||
let path = migration_context_path(&ctx.session_id)?;
|
||||
storage::write_json(&path, ctx)
|
||||
}
|
||||
|
||||
/// Load migration context
|
||||
pub fn load_migration_context(session_id: &str) -> Result<Option<MigrationContext>> {
|
||||
let path = migration_context_path(session_id)?;
|
||||
if path.exists() {
|
||||
Ok(Some(storage::read_json(&path)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear migration context after successful migration
|
||||
pub fn clear_migration_context(session_id: &str) -> Result<()> {
|
||||
let path = migration_context_path(session_id)?;
|
||||
if path.exists() {
|
||||
std::fs::remove_file(path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the current stable version
|
||||
pub fn read_stable_version() -> Result<Option<String>> {
|
||||
let path = stable_version_file()?;
|
||||
if path.exists() {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let hash = content.trim();
|
||||
if hash.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(hash.to_string()))
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the current active version.
|
||||
pub fn read_current_version() -> Result<Option<String>> {
|
||||
let path = current_version_file()?;
|
||||
if path.exists() {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let hash = content.trim();
|
||||
if hash.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(hash.to_string()))
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the current shared-server version.
|
||||
pub fn read_shared_server_version() -> Result<Option<String>> {
|
||||
let path = shared_server_version_file()?;
|
||||
if path.exists() {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let hash = content.trim();
|
||||
if hash.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(hash.to_string()))
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get path to build log file
|
||||
pub fn build_log_path() -> Result<PathBuf> {
|
||||
Ok(storage::jcode_dir()?.join("build.log"))
|
||||
}
|
||||
|
||||
/// Get path to build progress file (for TUI to watch)
|
||||
pub fn build_progress_path() -> Result<PathBuf> {
|
||||
Ok(storage::jcode_dir()?.join("build-progress"))
|
||||
}
|
||||
|
||||
/// Write current build progress (for TUI to display)
|
||||
pub fn write_build_progress(status: &str) -> Result<()> {
|
||||
let path = build_progress_path()?;
|
||||
std::fs::write(&path, status)?;
|
||||
invalidate_build_progress_cache();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Process-local cache for `read_build_progress`. Stores the last-read value
|
||||
/// alongside the time it was read so per-frame TUI calls can be served without
|
||||
/// a disk hit.
|
||||
static BUILD_PROGRESS_CACHE: std::sync::Mutex<Option<(std::time::Instant, Option<String>)>> =
|
||||
std::sync::Mutex::new(None);
|
||||
|
||||
const BUILD_PROGRESS_TTL: std::time::Duration = std::time::Duration::from_millis(100);
|
||||
|
||||
fn invalidate_build_progress_cache() {
|
||||
if let Ok(mut guard) = BUILD_PROGRESS_CACHE.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Read current build progress.
|
||||
///
|
||||
/// The TUI calls this from its per-frame redraw scheduler (several times per
|
||||
/// frame, across every connected client), so a naive implementation performs a
|
||||
/// synchronous disk read on every render tick even when no build is running.
|
||||
/// Build progress is a purely cosmetic status string, so we cache the result
|
||||
/// for a short window. The cache is invalidated immediately on
|
||||
/// `write_build_progress`/`clear_build_progress` so progress still updates
|
||||
/// promptly when a build is driven from the same process; cross-process updates
|
||||
/// become visible within the TTL.
|
||||
pub fn read_build_progress() -> Option<String> {
|
||||
if let Ok(guard) = BUILD_PROGRESS_CACHE.lock()
|
||||
&& let Some((at, ref value)) = *guard
|
||||
&& at.elapsed() < BUILD_PROGRESS_TTL
|
||||
{
|
||||
return value.clone();
|
||||
}
|
||||
|
||||
let value = read_build_progress_uncached();
|
||||
|
||||
if let Ok(mut guard) = BUILD_PROGRESS_CACHE.lock() {
|
||||
*guard = Some((std::time::Instant::now(), value.clone()));
|
||||
}
|
||||
|
||||
value
|
||||
}
|
||||
|
||||
fn read_build_progress_uncached() -> Option<String> {
|
||||
build_progress_path()
|
||||
.ok()
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Clear build progress
|
||||
pub fn clear_build_progress() -> Result<()> {
|
||||
let path = build_progress_path()?;
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path)?;
|
||||
}
|
||||
invalidate_build_progress_cache();
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,855 @@
|
||||
use super::*;
|
||||
|
||||
fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static ENV_LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
|
||||
ENV_LOCK
|
||||
.get_or_init(|| std::sync::Mutex::new(()))
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
fn with_temp_jcode_home<T>(f: impl FnOnce() -> T) -> T {
|
||||
let _guard = test_env_lock();
|
||||
let temp_home = tempfile::tempdir().expect("tempdir");
|
||||
let prev_home = std::env::var_os("JCODE_HOME");
|
||||
jcode_core::env::set_var("JCODE_HOME", temp_home.path());
|
||||
let result = f();
|
||||
if let Some(prev_home) = prev_home {
|
||||
jcode_core::env::set_var("JCODE_HOME", prev_home);
|
||||
} else {
|
||||
jcode_core::env::remove_var("JCODE_HOME");
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn create_git_repo_fixture() -> tempfile::TempDir {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::create_dir_all(temp.path().join(".git")).expect("create .git dir");
|
||||
std::fs::write(
|
||||
temp.path().join("Cargo.toml"),
|
||||
"[package]\nname = \"jcode\"\nversion = \"0.0.0\"\n",
|
||||
)
|
||||
.expect("write Cargo.toml");
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(temp.path())
|
||||
.output()
|
||||
.expect("git init");
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@example.com"])
|
||||
.current_dir(temp.path())
|
||||
.output()
|
||||
.expect("git config email");
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test User"])
|
||||
.current_dir(temp.path())
|
||||
.output()
|
||||
.expect("git config name");
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "Cargo.toml"])
|
||||
.current_dir(temp.path())
|
||||
.output()
|
||||
.expect("git add");
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(temp.path())
|
||||
.output()
|
||||
.expect("git commit");
|
||||
temp
|
||||
}
|
||||
|
||||
fn source_state_fixture(short_hash: &str, fingerprint: &str) -> SourceState {
|
||||
SourceState {
|
||||
repo_scope: "repo-scope".to_string(),
|
||||
worktree_scope: "worktree-scope".to_string(),
|
||||
short_hash: short_hash.to_string(),
|
||||
full_hash: format!("{short_hash}-full"),
|
||||
dirty: true,
|
||||
fingerprint: fingerprint.to_string(),
|
||||
version_label: format!("{short_hash}-dirty-{}", &fingerprint[..12]),
|
||||
changed_paths: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_manifest_default() {
|
||||
let manifest = BuildManifest::default();
|
||||
assert!(manifest.stable.is_none());
|
||||
assert!(manifest.canary.is_none());
|
||||
assert!(manifest.history.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_binary_version_hash_mismatch_rejects_publish_candidate() {
|
||||
let source = source_state_fixture("newhash", "123456789abcffff");
|
||||
let report = BinaryVersionReport {
|
||||
version: Some("v0.0.0-dev (oldhash, dirty)".to_string()),
|
||||
git_hash: Some("oldhash".to_string()),
|
||||
};
|
||||
|
||||
let error = validate_binary_version_matches_source_report(&report, Path::new("jcode"), &source)
|
||||
.expect_err("mismatched git hash should be rejected");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("binary was built from git hash oldhash")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dev_binary_source_metadata_mismatch_rejects_publish_candidate() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let binary = temp.path().join(binary_name());
|
||||
std::fs::write(&binary, b"fake").expect("write fake binary");
|
||||
let source = source_state_fixture("abc1234", "1111111111112222");
|
||||
let stale_source = source_state_fixture("abc1234", "999999999999aaaa");
|
||||
write_dev_binary_source_metadata(&binary, &stale_source).expect("write metadata");
|
||||
|
||||
let error = validate_dev_binary_source_metadata(&binary, &source)
|
||||
.expect_err("mismatched source metadata should be rejected");
|
||||
|
||||
assert!(error.to_string().contains("source metadata"));
|
||||
assert!(error.to_string().contains("999999999999aaaa"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_smoke_test_server_protocol_uses_fresh_connection_after_ping() {
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixListener;
|
||||
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let socket_path = temp.path().join("smoke.sock");
|
||||
let listener = UnixListener::bind(&socket_path).expect("bind unix listener");
|
||||
|
||||
let server = std::thread::spawn(move || {
|
||||
let (first, _) = listener.accept().expect("accept ping client");
|
||||
let mut first = BufReader::new(first);
|
||||
let mut line = String::new();
|
||||
first.read_line(&mut line).expect("read ping request");
|
||||
assert!(line.contains("\"type\":\"ping\""));
|
||||
first
|
||||
.get_mut()
|
||||
.write_all(b"{\"type\":\"pong\",\"id\":1}\n")
|
||||
.expect("write pong");
|
||||
|
||||
let (second, _) = listener.accept().expect("accept subscribe client");
|
||||
let mut second = BufReader::new(second);
|
||||
line.clear();
|
||||
second.read_line(&mut line).expect("read subscribe request");
|
||||
assert!(line.contains("\"type\":\"subscribe\""));
|
||||
second
|
||||
.get_mut()
|
||||
.write_all(b"{\"type\":\"ack\",\"id\":2}\n")
|
||||
.expect("write subscribe ack");
|
||||
});
|
||||
|
||||
smoke_test_server_protocol(&socket_path, "/tmp").expect("smoke test protocol succeeds");
|
||||
server.join().expect("server thread join");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_binary_choice_for_canary_session() {
|
||||
let manifest = BuildManifest {
|
||||
canary: Some("abc123".to_string()),
|
||||
canary_session: Some("session_test".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Canary session should get canary binary
|
||||
match manifest.binary_for_session("session_test") {
|
||||
BinaryChoice::Canary(hash) => assert_eq!(hash, "abc123"),
|
||||
_ => panic!("Expected canary binary"),
|
||||
}
|
||||
|
||||
// Other sessions should get stable (or current if no stable)
|
||||
match manifest.binary_for_session("other_session") {
|
||||
BinaryChoice::Current => {}
|
||||
_ => panic!("Expected current binary"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_repo_in_ancestors_walks_upward() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let repo = temp.path().join("jcode-repo");
|
||||
let nested = repo.join("a").join("b").join("c");
|
||||
|
||||
std::fs::create_dir_all(repo.join(".git")).expect("create .git");
|
||||
std::fs::write(
|
||||
repo.join("Cargo.toml"),
|
||||
"[package]\nname = \"jcode\"\nversion = \"0.0.0\"\n",
|
||||
)
|
||||
.expect("write Cargo.toml");
|
||||
std::fs::create_dir_all(&nested).expect("create nested dirs");
|
||||
|
||||
let found = find_repo_in_ancestors(&nested).expect("repo should be found");
|
||||
assert_eq!(found, repo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_update_candidate_prefers_dev_binary_for_selfdev() {
|
||||
let _guard = test_env_lock();
|
||||
let temp_home = tempfile::tempdir().expect("tempdir");
|
||||
let prev_home = std::env::var_os("JCODE_HOME");
|
||||
jcode_core::env::set_var("JCODE_HOME", temp_home.path());
|
||||
|
||||
let version = "test-current";
|
||||
let version_binary =
|
||||
install_binary_at_version(std::env::current_exe().as_ref().unwrap(), version)
|
||||
.expect("install test version");
|
||||
update_current_symlink(version).expect("update current symlink");
|
||||
|
||||
let candidate = client_update_candidate(true).expect("expected selfdev candidate");
|
||||
assert_eq!(candidate.1, "current");
|
||||
assert_eq!(
|
||||
std::fs::canonicalize(candidate.0).expect("canonical candidate"),
|
||||
std::fs::canonicalize(version_binary).expect("canonical version binary")
|
||||
);
|
||||
|
||||
if let Some(prev_home) = prev_home {
|
||||
jcode_core::env::set_var("JCODE_HOME", prev_home);
|
||||
} else {
|
||||
jcode_core::env::remove_var("JCODE_HOME");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launcher_dir_uses_sandbox_bin_when_jcode_home_is_set() {
|
||||
with_temp_jcode_home(|| {
|
||||
let launcher_dir = launcher_dir().expect("launcher dir");
|
||||
let expected = storage::jcode_dir().expect("jcode dir").join("bin");
|
||||
assert_eq!(launcher_dir, expected);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_launcher_symlink_stays_inside_sandbox_home() {
|
||||
with_temp_jcode_home(|| {
|
||||
let version = "sandbox-current";
|
||||
let version_binary =
|
||||
install_binary_at_version(std::env::current_exe().as_ref().unwrap(), version)
|
||||
.expect("install test version");
|
||||
update_current_symlink(version).expect("update current symlink");
|
||||
|
||||
let launcher = update_launcher_symlink_to_current().expect("update launcher");
|
||||
let expected_launcher = storage::jcode_dir()
|
||||
.expect("jcode dir")
|
||||
.join("bin")
|
||||
.join(binary_name());
|
||||
assert_eq!(launcher, expected_launcher);
|
||||
assert_eq!(
|
||||
std::fs::canonicalize(&launcher).expect("canonical launcher"),
|
||||
std::fs::canonicalize(version_binary).expect("canonical version binary")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_canary_status_serialization() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&CanaryStatus::Testing).unwrap(),
|
||||
"\"testing\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&CanaryStatus::Passed).unwrap(),
|
||||
"\"passed\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_source_state_uses_fingerprint_in_version_label() {
|
||||
let repo = create_git_repo_fixture();
|
||||
std::fs::write(repo.path().join("notes.txt"), "dirty change\n").expect("write dirty file");
|
||||
|
||||
let state = current_source_state(repo.path()).expect("source state");
|
||||
assert!(state.dirty);
|
||||
assert!(
|
||||
state
|
||||
.version_label
|
||||
.starts_with(&format!("{}-dirty-", state.short_hash))
|
||||
);
|
||||
assert!(state.version_label.len() > state.short_hash.len() + 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_activation_can_complete_and_roll_back() {
|
||||
with_temp_jcode_home(|| {
|
||||
let current_version = "stable-prev";
|
||||
let shared_version = "shared-prev";
|
||||
install_binary_at_version(std::env::current_exe().as_ref().unwrap(), current_version)
|
||||
.expect("install previous version");
|
||||
install_binary_at_version(std::env::current_exe().as_ref().unwrap(), shared_version)
|
||||
.expect("install previous shared version");
|
||||
update_current_symlink(current_version).expect("publish previous current");
|
||||
update_shared_server_symlink(shared_version).expect("publish previous shared");
|
||||
|
||||
let mut manifest = BuildManifest::default();
|
||||
manifest
|
||||
.set_pending_activation(PendingActivation {
|
||||
session_id: "session-a".to_string(),
|
||||
new_version: "canary-next".to_string(),
|
||||
previous_current_version: Some(current_version.to_string()),
|
||||
previous_shared_server_version: Some(shared_version.to_string()),
|
||||
source_fingerprint: Some("fingerprint-a".to_string()),
|
||||
requested_at: Utc::now(),
|
||||
})
|
||||
.expect("set pending activation");
|
||||
|
||||
let completed = complete_pending_activation_for_session("session-a")
|
||||
.expect("complete activation")
|
||||
.expect("completed version");
|
||||
assert_eq!(completed, "canary-next");
|
||||
let manifest = BuildManifest::load().expect("load manifest");
|
||||
assert!(manifest.pending_activation.is_none());
|
||||
assert_eq!(manifest.canary.as_deref(), Some("canary-next"));
|
||||
assert_eq!(manifest.canary_status, Some(CanaryStatus::Passed));
|
||||
|
||||
let mut manifest = BuildManifest::load().expect("reload manifest");
|
||||
manifest
|
||||
.set_pending_activation(PendingActivation {
|
||||
session_id: "session-b".to_string(),
|
||||
new_version: "canary-bad".to_string(),
|
||||
previous_current_version: Some(current_version.to_string()),
|
||||
previous_shared_server_version: Some(shared_version.to_string()),
|
||||
source_fingerprint: Some("fingerprint-b".to_string()),
|
||||
requested_at: Utc::now(),
|
||||
})
|
||||
.expect("set second pending activation");
|
||||
|
||||
let rolled_back = rollback_pending_activation_for_session("session-b")
|
||||
.expect("rollback activation")
|
||||
.expect("rolled back version");
|
||||
assert_eq!(rolled_back, "canary-bad");
|
||||
let restored = read_current_version()
|
||||
.expect("read current version")
|
||||
.expect("restored current version");
|
||||
assert_eq!(restored, current_version);
|
||||
let restored_shared = read_shared_server_version()
|
||||
.expect("read shared server version")
|
||||
.expect("restored shared server version");
|
||||
assert_eq!(restored_shared, shared_version);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_server_candidate_prefers_approved_channel_over_current() {
|
||||
with_temp_jcode_home(|| {
|
||||
let approved_version = "shared-ok";
|
||||
let current_version = "current-dev";
|
||||
install_binary_at_version(std::env::current_exe().as_ref().unwrap(), approved_version)
|
||||
.expect("install approved version");
|
||||
install_binary_at_version(std::env::current_exe().as_ref().unwrap(), current_version)
|
||||
.expect("install current version");
|
||||
update_shared_server_symlink(approved_version).expect("update shared server");
|
||||
update_current_symlink(current_version).expect("update current");
|
||||
|
||||
let candidate =
|
||||
shared_server_update_candidate(true).expect("expected shared-server candidate");
|
||||
assert_eq!(candidate.1, "shared-server");
|
||||
let selected = std::fs::canonicalize(candidate.0).expect("canonical selected");
|
||||
let approved = std::fs::canonicalize(version_binary_path(approved_version).unwrap())
|
||||
.expect("canonical approved");
|
||||
assert_eq!(selected, approved);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_shared_server_candidate_repairs_stale_shared_channel_to_stable() {
|
||||
with_temp_jcode_home(|| {
|
||||
let stale_version = "0.14.2";
|
||||
let installed_version = "0.17.0";
|
||||
install_binary_at_version(std::env::current_exe().as_ref().unwrap(), stale_version)
|
||||
.expect("install stale shared version");
|
||||
install_binary_at_version(std::env::current_exe().as_ref().unwrap(), installed_version)
|
||||
.expect("install installed version");
|
||||
update_shared_server_symlink(stale_version).expect("update shared server");
|
||||
update_stable_symlink(installed_version).expect("update stable");
|
||||
update_current_symlink(installed_version).expect("update current");
|
||||
|
||||
let candidate =
|
||||
shared_server_update_candidate(false).expect("expected stable shared-server candidate");
|
||||
assert_eq!(candidate.1, "stable");
|
||||
let selected = std::fs::canonicalize(candidate.0).expect("canonical selected");
|
||||
let installed = std::fs::canonicalize(version_binary_path(installed_version).unwrap())
|
||||
.expect("canonical installed");
|
||||
assert_eq!(selected, installed);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_shared_server_candidate_allows_shared_channel_matching_stable() {
|
||||
with_temp_jcode_home(|| {
|
||||
let installed_version = "0.17.0";
|
||||
install_binary_at_version(std::env::current_exe().as_ref().unwrap(), installed_version)
|
||||
.expect("install installed version");
|
||||
update_shared_server_symlink(installed_version).expect("update shared server");
|
||||
update_stable_symlink(installed_version).expect("update stable");
|
||||
|
||||
let candidate = shared_server_update_candidate(false)
|
||||
.expect("expected matching shared-server candidate");
|
||||
assert_eq!(candidate.1, "shared-server");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_shared_server_candidate_ignores_shared_channel_with_missing_marker() {
|
||||
with_temp_jcode_home(|| {
|
||||
let shared_version = "0.14.2";
|
||||
let installed_version = "0.17.0";
|
||||
install_binary_at_version(std::env::current_exe().as_ref().unwrap(), shared_version)
|
||||
.expect("install shared version");
|
||||
install_binary_at_version(std::env::current_exe().as_ref().unwrap(), installed_version)
|
||||
.expect("install installed version");
|
||||
update_shared_server_symlink(shared_version).expect("update shared server");
|
||||
std::fs::remove_file(shared_server_version_file().unwrap()).expect("remove marker");
|
||||
update_stable_symlink(installed_version).expect("update stable");
|
||||
|
||||
let candidate = shared_server_update_candidate(false)
|
||||
.expect("expected stable candidate when shared marker is missing");
|
||||
assert_eq!(candidate.1, "stable");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_shared_server_candidate_ignores_shared_channel_with_corrupt_marker() {
|
||||
with_temp_jcode_home(|| {
|
||||
let shared_version = "0.14.2";
|
||||
let installed_version = "0.17.0";
|
||||
install_binary_at_version(std::env::current_exe().as_ref().unwrap(), shared_version)
|
||||
.expect("install shared version");
|
||||
install_binary_at_version(std::env::current_exe().as_ref().unwrap(), installed_version)
|
||||
.expect("install installed version");
|
||||
update_shared_server_symlink(shared_version).expect("update shared server");
|
||||
std::fs::write(
|
||||
shared_server_version_file().unwrap(),
|
||||
"not-the-installed-version",
|
||||
)
|
||||
.expect("write corrupt marker");
|
||||
update_stable_symlink(installed_version).expect("update stable");
|
||||
|
||||
let candidate = shared_server_update_candidate(false)
|
||||
.expect("expected stable candidate when shared marker is corrupt");
|
||||
assert_eq!(candidate.1, "stable");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_match_detects_installed_channel_by_semver_or_git_hash() {
|
||||
with_temp_jcode_home(|| {
|
||||
std::fs::create_dir_all(builds_dir().unwrap()).expect("create builds dir");
|
||||
std::fs::write(stable_version_file().unwrap(), "0.17.0").expect("write stable marker");
|
||||
assert!(version_matches_installed_channel(
|
||||
"v0.17.0 (abc1234)",
|
||||
"different"
|
||||
));
|
||||
assert!(!version_matches_installed_channel("v0.14.2", "different"));
|
||||
|
||||
std::fs::write(stable_version_file().unwrap(), "abc1234-dirty-build")
|
||||
.expect("write git marker");
|
||||
assert!(version_matches_installed_channel(
|
||||
"v0.14.2-dev (abc1234)",
|
||||
"abc1234"
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_server_tracks_stable_when_marker_missing() {
|
||||
with_temp_jcode_home(|| {
|
||||
std::fs::create_dir_all(builds_dir().unwrap()).expect("create builds dir");
|
||||
// No shared-server marker at all: nothing deliberate to protect.
|
||||
assert!(shared_server_tracks_stable().expect("tracks stable"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_server_tracks_stable_when_equal_to_stable() {
|
||||
with_temp_jcode_home(|| {
|
||||
std::fs::create_dir_all(builds_dir().unwrap()).expect("create builds dir");
|
||||
std::fs::write(stable_version_file().unwrap(), "0.17.0").expect("write stable");
|
||||
std::fs::write(shared_server_version_file().unwrap(), "0.17.0").expect("write shared");
|
||||
assert!(shared_server_tracks_stable().expect("tracks stable"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_server_does_not_track_stable_when_pinned_to_selfdev() {
|
||||
with_temp_jcode_home(|| {
|
||||
std::fs::create_dir_all(builds_dir().unwrap()).expect("create builds dir");
|
||||
std::fs::write(stable_version_file().unwrap(), "0.17.0").expect("write stable");
|
||||
std::fs::write(
|
||||
shared_server_version_file().unwrap(),
|
||||
"56f43c3d-dirty-deadbeef",
|
||||
)
|
||||
.expect("write shared");
|
||||
assert!(!shared_server_tracks_stable().expect("does not track stable"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_shared_server_carries_forward_when_tracking_stable() {
|
||||
with_temp_jcode_home(|| {
|
||||
let old = "0.17.0";
|
||||
let new = "0.18.0";
|
||||
install_binary_at_version(std::env::current_exe().as_ref().unwrap(), old)
|
||||
.expect("install old");
|
||||
install_binary_at_version(std::env::current_exe().as_ref().unwrap(), new)
|
||||
.expect("install new");
|
||||
update_stable_symlink(old).expect("stable old");
|
||||
update_shared_server_symlink(old).expect("shared old");
|
||||
|
||||
let advanced = advance_shared_server_if_tracking_stable(new).expect("advance");
|
||||
assert!(advanced);
|
||||
assert_eq!(
|
||||
read_shared_server_version().unwrap().as_deref(),
|
||||
Some(new),
|
||||
"shared-server should follow the update"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_shared_server_preserves_pinned_selfdev_build() {
|
||||
with_temp_jcode_home(|| {
|
||||
let stable_old = "0.17.0";
|
||||
let selfdev = "56f43c3d-dirty-deadbeef";
|
||||
let update = "0.18.0";
|
||||
install_binary_at_version(std::env::current_exe().as_ref().unwrap(), stable_old)
|
||||
.expect("install stable");
|
||||
install_binary_at_version(std::env::current_exe().as_ref().unwrap(), selfdev)
|
||||
.expect("install selfdev");
|
||||
install_binary_at_version(std::env::current_exe().as_ref().unwrap(), update)
|
||||
.expect("install update");
|
||||
update_stable_symlink(stable_old).expect("stable");
|
||||
update_shared_server_symlink(selfdev).expect("shared selfdev");
|
||||
|
||||
let advanced = advance_shared_server_if_tracking_stable(update).expect("advance");
|
||||
assert!(!advanced, "must not advance a deliberately-promoted build");
|
||||
assert_eq!(
|
||||
read_shared_server_version().unwrap().as_deref(),
|
||||
Some(selfdev),
|
||||
"self-dev shared-server build must be preserved across update"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Simulate the channel mutations performed by `/update`'s stable install path
|
||||
/// (`download_and_install_blocking_with_progress`), without doing any network
|
||||
/// I/O. This is the exact sequence: advance shared-server if tracking stable,
|
||||
/// then move stable/current/launcher to the freshly installed version.
|
||||
fn simulate_stable_update_channel_swap(new_version: &str) {
|
||||
install_binary_at_version(std::env::current_exe().as_ref().unwrap(), new_version)
|
||||
.expect("install update version");
|
||||
// /update tries to carry the daemon's reload target forward, but only when
|
||||
// shared-server is tracking stable.
|
||||
advance_shared_server_if_tracking_stable(new_version).expect("advance shared-server");
|
||||
update_stable_symlink(new_version).expect("update stable");
|
||||
update_current_symlink(new_version).expect("update current");
|
||||
update_launcher_symlink_to_current().expect("update launcher");
|
||||
}
|
||||
|
||||
/// Resolve the binary the long-lived daemon would actually reload into for a
|
||||
/// *normal* (non-self-dev) session. This mirrors `reload_exec_target` /
|
||||
/// `server_update_candidate` in the server, which both go through
|
||||
/// `shared_server_update_candidate(false)`.
|
||||
fn daemon_reload_target_version() -> Option<String> {
|
||||
let (candidate, _label) = shared_server_update_candidate(false)?;
|
||||
let canonical = std::fs::canonicalize(&candidate).unwrap_or(candidate);
|
||||
// versions/<version>/jcode -> <version>
|
||||
canonical
|
||||
.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// Reproduces the user-reported "/update gives the new client but a stale
|
||||
/// server" bug.
|
||||
///
|
||||
/// Repro setup matches a real self-dev machine state observed in the field:
|
||||
/// the `shared-server` channel is pinned to a self-dev build that differs from
|
||||
/// `stable`. When the user runs `/update`, the client channels advance to the
|
||||
/// new release, but `advance_shared_server_if_tracking_stable` refuses to move
|
||||
/// the pinned shared-server channel, so the daemon's reload target stays on the
|
||||
/// old self-dev binary forever.
|
||||
///
|
||||
/// EXPECTED (post-fix): after `/update`, the daemon's reload target resolves to
|
||||
/// the freshly installed release version, so a reconnecting client can upgrade
|
||||
/// the server too.
|
||||
#[test]
|
||||
fn update_leaves_daemon_reload_target_stale_when_shared_server_pinned_to_selfdev() {
|
||||
with_temp_jcode_home(|| {
|
||||
// Field state: client + server both on an old self-dev build.
|
||||
let old_selfdev = "3f160da1-dirty-e756d52efca9";
|
||||
install_binary_at_version(std::env::current_exe().as_ref().unwrap(), old_selfdev)
|
||||
.expect("install old selfdev");
|
||||
// `stable` lags behind (a previously released version).
|
||||
let old_stable = "0.14.3";
|
||||
install_binary_at_version(std::env::current_exe().as_ref().unwrap(), old_stable)
|
||||
.expect("install old stable");
|
||||
update_stable_symlink(old_stable).expect("stable");
|
||||
update_current_symlink(old_selfdev).expect("current selfdev");
|
||||
update_shared_server_symlink(old_selfdev).expect("shared-server selfdev");
|
||||
|
||||
// User runs `/update`: a newer release ships and the client installs it.
|
||||
let new_release = "0.15.0";
|
||||
simulate_stable_update_channel_swap(new_release);
|
||||
|
||||
// Client side is upgraded: current + stable now point at the release.
|
||||
assert_eq!(
|
||||
read_current_version().unwrap().as_deref(),
|
||||
Some(new_release),
|
||||
"client `current` channel should advance on /update"
|
||||
);
|
||||
assert_eq!(
|
||||
read_stable_version().unwrap().as_deref(),
|
||||
Some(new_release),
|
||||
"client `stable` channel should advance on /update"
|
||||
);
|
||||
|
||||
// Server side: what would the daemon reload into? This is the bug.
|
||||
let target = daemon_reload_target_version();
|
||||
assert_eq!(
|
||||
target.as_deref(),
|
||||
Some(new_release),
|
||||
"BUG: after /update the daemon's reload target is still stale \
|
||||
(shared-server pinned to {old_selfdev}); the user gets a new client \
|
||||
but the long-lived server never upgrades. shared-server-version={:?}",
|
||||
read_shared_server_version().unwrap()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Control case: when `shared-server` is tracking `stable` (the normal,
|
||||
/// non-self-dev install), `/update` correctly advances the daemon's reload
|
||||
/// target. This guards against a fix that over-corrects and breaks the healthy
|
||||
/// path.
|
||||
#[test]
|
||||
fn update_advances_daemon_reload_target_when_shared_server_tracks_stable() {
|
||||
with_temp_jcode_home(|| {
|
||||
let old_release = "0.14.3";
|
||||
install_binary_at_version(std::env::current_exe().as_ref().unwrap(), old_release)
|
||||
.expect("install old release");
|
||||
update_stable_symlink(old_release).expect("stable");
|
||||
update_current_symlink(old_release).expect("current");
|
||||
update_shared_server_symlink(old_release).expect("shared-server tracks stable");
|
||||
|
||||
let new_release = "0.15.0";
|
||||
simulate_stable_update_channel_swap(new_release);
|
||||
|
||||
assert_eq!(
|
||||
daemon_reload_target_version().as_deref(),
|
||||
Some(new_release),
|
||||
"daemon reload target should advance with /update when tracking stable"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn candidate_version(candidate: Option<(PathBuf, &'static str)>) -> Option<String> {
|
||||
let (candidate, _label) = candidate?;
|
||||
let canonical = std::fs::canonicalize(&candidate).unwrap_or(candidate);
|
||||
canonical
|
||||
.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// Documents the channel-level precondition behind the "/update -> new client,
|
||||
/// stale server" bug for a self-dev / canary daemon.
|
||||
///
|
||||
/// The daemon decides "is a server update available?" via `server_has_newer_binary`,
|
||||
/// which scans BOTH candidate flavors (`shared_server_update_candidate(false)`
|
||||
/// AND `(true)`). After `/update`, the `false` flavor self-heals to the freshly
|
||||
/// installed release, so the daemon reports `server_has_update = true`.
|
||||
///
|
||||
/// The single-flavor reload target, however, diverges: a self-dev/canary session
|
||||
/// resolves `shared_server_update_candidate(true)`, which returns the *pinned*
|
||||
/// old shared-server binary == the running daemon. So if the daemon naively
|
||||
/// reloaded into only its own flavor it would exec back into the same old binary,
|
||||
/// never upgrade, and loop on the still-true update signal.
|
||||
///
|
||||
/// The fix lives in `server::util::reload_exec_target`, which now selects the
|
||||
/// *newest* candidate across both flavors so the reload target matches the
|
||||
/// advertised update. This test pins the channel-level divergence that motivates
|
||||
/// that fix.
|
||||
#[test]
|
||||
fn selfdev_reload_target_diverges_from_update_probe_when_shared_server_pinned() {
|
||||
with_temp_jcode_home(|| {
|
||||
let old_selfdev = "3f160da1-dirty-e756d52efca9";
|
||||
install_binary_at_version(std::env::current_exe().as_ref().unwrap(), old_selfdev)
|
||||
.expect("install old selfdev");
|
||||
let old_stable = "0.14.3";
|
||||
install_binary_at_version(std::env::current_exe().as_ref().unwrap(), old_stable)
|
||||
.expect("install old stable");
|
||||
update_stable_symlink(old_stable).expect("stable");
|
||||
update_current_symlink(old_selfdev).expect("current selfdev");
|
||||
update_shared_server_symlink(old_selfdev).expect("shared-server pinned selfdev");
|
||||
|
||||
let new_release = "0.15.0";
|
||||
simulate_stable_update_channel_swap(new_release);
|
||||
|
||||
// The "is there a server update?" probe (false flavor) self-heals and
|
||||
// sees the new release, so the daemon advertises an update.
|
||||
let update_probe = candidate_version(shared_server_update_candidate(false));
|
||||
assert_eq!(
|
||||
update_probe.as_deref(),
|
||||
Some(new_release),
|
||||
"server_has_newer_binary's normal-candidate probe should see the new release \
|
||||
(this is what makes the daemon advertise server_has_update = true)"
|
||||
);
|
||||
|
||||
// A self-dev/canary session's OWN flavor stays pinned to the OLD binary.
|
||||
// This single-flavor divergence is what `reload_exec_target` must
|
||||
// reconcile by taking the newest candidate across both flavors.
|
||||
let selfdev_reload_target = candidate_version(shared_server_update_candidate(true));
|
||||
assert_eq!(
|
||||
selfdev_reload_target.as_deref(),
|
||||
Some(old_selfdev),
|
||||
"self-dev single-flavor reload target stays pinned to the old binary"
|
||||
);
|
||||
|
||||
assert_ne!(
|
||||
selfdev_reload_target, update_probe,
|
||||
"the single-flavor self-dev reload target diverges from the advertised update; \
|
||||
reload_exec_target reconciles this by preferring the newest candidate across flavors"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Write a distinct, real binary into `versions/<version>/jcode` with an
|
||||
/// explicit mtime so channel-repair mtime comparisons are deterministic
|
||||
/// (install_binary_at_version hard-links and would share an mtime).
|
||||
fn write_versioned_binary(version: &str, mtime: std::time::SystemTime) -> PathBuf {
|
||||
let dir = builds_dir().unwrap().join("versions").join(version);
|
||||
std::fs::create_dir_all(&dir).expect("create version dir");
|
||||
let path = dir.join(binary_name());
|
||||
std::fs::write(&path, format!("bin {version}")).expect("write binary");
|
||||
std::fs::File::open(&path)
|
||||
.expect("open binary")
|
||||
.set_modified(mtime)
|
||||
.expect("set mtime");
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_repoints_stale_shared_server_to_newer_stable() {
|
||||
use std::time::{Duration, SystemTime};
|
||||
with_temp_jcode_home(|| {
|
||||
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
|
||||
let old = "0.14.6";
|
||||
let new = "0.22.0";
|
||||
// shared-server pinned to the OLD build; stable advanced to the NEW
|
||||
// release (the "current client, no-op /update, stale server" state).
|
||||
write_versioned_binary(old, base);
|
||||
write_versioned_binary(new, base + Duration::from_secs(60));
|
||||
update_shared_server_symlink(old).expect("pin shared-server old");
|
||||
update_stable_symlink(new).expect("stable new");
|
||||
|
||||
let outcome = repair_stale_shared_server_channel().expect("repair");
|
||||
assert_eq!(
|
||||
outcome,
|
||||
SharedServerRepair::Repaired {
|
||||
previous: Some(old.to_string()),
|
||||
repaired_to: new.to_string(),
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
read_shared_server_version().unwrap().as_deref(),
|
||||
Some(new),
|
||||
"shared-server should be dragged forward to stable"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_is_noop_when_shared_server_already_matches_stable() {
|
||||
use std::time::{Duration, SystemTime};
|
||||
with_temp_jcode_home(|| {
|
||||
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
|
||||
let v = "0.22.0";
|
||||
write_versioned_binary(v, base);
|
||||
update_shared_server_symlink(v).expect("shared");
|
||||
update_stable_symlink(v).expect("stable");
|
||||
|
||||
assert_eq!(
|
||||
repair_stale_shared_server_channel().expect("repair"),
|
||||
SharedServerRepair::AlreadyCurrent,
|
||||
);
|
||||
assert_eq!(read_shared_server_version().unwrap().as_deref(), Some(v));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_preserves_fresher_selfdev_pin() {
|
||||
use std::time::{Duration, SystemTime};
|
||||
with_temp_jcode_home(|| {
|
||||
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
|
||||
let stable_old = "0.14.3";
|
||||
let selfdev_new = "56f43c3d-dirty-deadbeef";
|
||||
// Deliberately-promoted self-dev build that is NEWER than stable must be
|
||||
// preserved (the whole point of pinning shared-server).
|
||||
write_versioned_binary(stable_old, base);
|
||||
write_versioned_binary(selfdev_new, base + Duration::from_secs(120));
|
||||
update_stable_symlink(stable_old).expect("stable");
|
||||
update_shared_server_symlink(selfdev_new).expect("pin newer self-dev");
|
||||
|
||||
assert_eq!(
|
||||
repair_stale_shared_server_channel().expect("repair"),
|
||||
SharedServerRepair::AlreadyCurrent,
|
||||
"must not downgrade a fresher self-dev pin to an older stable"
|
||||
);
|
||||
assert_eq!(
|
||||
read_shared_server_version().unwrap().as_deref(),
|
||||
Some(selfdev_new),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_preserves_older_selfdev_pin() {
|
||||
use std::time::{Duration, SystemTime};
|
||||
with_temp_jcode_home(|| {
|
||||
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
|
||||
let selfdev_old = "56f43c3d-dirty-deadbeef";
|
||||
let stable_new = "0.22.0";
|
||||
write_versioned_binary(selfdev_old, base);
|
||||
write_versioned_binary(stable_new, base + Duration::from_secs(120));
|
||||
update_shared_server_symlink(selfdev_old).expect("pin older self-dev");
|
||||
update_stable_symlink(stable_new).expect("stable new");
|
||||
|
||||
assert_eq!(
|
||||
repair_stale_shared_server_channel().expect("repair"),
|
||||
SharedServerRepair::AlreadyCurrent,
|
||||
"repair must not overwrite a deliberately-pinned self-dev build"
|
||||
);
|
||||
assert_eq!(
|
||||
read_shared_server_version().unwrap().as_deref(),
|
||||
Some(selfdev_old),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_never_downgrades_when_stable_is_older() {
|
||||
use std::time::{Duration, SystemTime};
|
||||
with_temp_jcode_home(|| {
|
||||
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
|
||||
let shared_new = "0.22.0";
|
||||
let stable_old = "0.14.3";
|
||||
write_versioned_binary(stable_old, base);
|
||||
write_versioned_binary(shared_new, base + Duration::from_secs(90));
|
||||
update_shared_server_symlink(shared_new).expect("shared new");
|
||||
update_stable_symlink(stable_old).expect("stable old");
|
||||
|
||||
assert_eq!(
|
||||
repair_stale_shared_server_channel().expect("repair"),
|
||||
SharedServerRepair::AlreadyCurrent,
|
||||
"repair must never move shared-server backward to an older stable"
|
||||
);
|
||||
assert_eq!(
|
||||
read_shared_server_version().unwrap().as_deref(),
|
||||
Some(shared_new),
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user