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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:34 +08:00
commit a789495a98
1551 changed files with 718128 additions and 0 deletions
+18
View File
@@ -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(())
}
+924
View File
@@ -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;
+699
View File
@@ -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(&current)
}
/// 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, &current) => {
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(())
}
+855
View File
@@ -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),
);
});
}