mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:23:33 +00:00
fix(core/mcp): honor per-server startup_timeout_ms and make tools/list failures non-fatal; add test MCP server + smoke harness to validate slow/fast scenarios
- Add to and parse/write in config - Apply per-server timeout to and initial - Treat per-server errors as non-fatal; record and skip those servers - Keep manager creation successful even if some servers time out - Export so local smoke tool can exercise it - Add (stdio JSON-RPC) and binary for manual validation - Update TUI to set by default This resolves GitHub issue #91 where a slow MCP server caused manager creation to time out and fail entirely.
This commit is contained in:
Generated
+17
@@ -1047,6 +1047,23 @@ dependencies = [
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-mcp-smoke"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"codex-core",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-mcp-test-server"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-ollama"
|
||||
version = "0.0.0"
|
||||
|
||||
@@ -14,6 +14,8 @@ members = [
|
||||
"login",
|
||||
"mcp-client",
|
||||
"mcp-server",
|
||||
"mcp-test-server",
|
||||
"mcp-smoke",
|
||||
"mcp-types",
|
||||
"protocol",
|
||||
"ollama",
|
||||
|
||||
@@ -663,7 +663,15 @@ pub fn list_mcp_servers(codex_home: &Path) -> anyhow::Result<(
|
||||
.filter_map(|(k, v)| v.as_str().map(|s| (k.to_string(), s.to_string())))
|
||||
.collect()
|
||||
});
|
||||
out.push((name.to_string(), McpServerConfig { command, args, env }));
|
||||
let startup_timeout_ms = t
|
||||
.get("startup_timeout_ms")
|
||||
.and_then(|v| v.as_integer())
|
||||
.map(|i| i as u64);
|
||||
|
||||
out.push((
|
||||
name.to_string(),
|
||||
McpServerConfig { command, args, env, startup_timeout_ms },
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -731,6 +739,9 @@ pub fn add_mcp_server(
|
||||
}
|
||||
server_tbl.insert("env", TomlItem::Value(toml_edit::Value::InlineTable(it)));
|
||||
}
|
||||
if let Some(ms) = cfg.startup_timeout_ms {
|
||||
server_tbl.insert("startup_timeout_ms", TomlItem::Value(toml_edit::Value::from(ms as i64)));
|
||||
}
|
||||
|
||||
// Write into enabled table
|
||||
tbl.insert(name, TomlItem::Table(server_tbl));
|
||||
|
||||
@@ -66,6 +66,12 @@ pub struct McpServerConfig {
|
||||
|
||||
#[serde(default)]
|
||||
pub env: Option<HashMap<String, String>>,
|
||||
|
||||
/// Optional per-server startup timeout in milliseconds.
|
||||
/// Applies to both the initial `initialize` handshake and the first
|
||||
/// `tools/list` request during startup. If unset, defaults to 10_000ms.
|
||||
#[serde(default)]
|
||||
pub startup_timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Copy, Clone, PartialEq)]
|
||||
|
||||
@@ -37,7 +37,7 @@ pub mod git_worktree;
|
||||
mod image_comparison;
|
||||
mod is_safe_command;
|
||||
pub mod landlock;
|
||||
mod mcp_connection_manager;
|
||||
pub mod mcp_connection_manager;
|
||||
mod mcp_tool_call;
|
||||
mod message_history;
|
||||
mod model_provider_info;
|
||||
|
||||
@@ -37,7 +37,7 @@ const MCP_TOOL_NAME_DELIMITER: &str = "__";
|
||||
const MAX_TOOL_NAME_LENGTH: usize = 64;
|
||||
|
||||
/// Timeout for the `tools/list` request.
|
||||
const LIST_TOOLS_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Map that holds a startup error for every MCP server that could **not** be
|
||||
/// spawned successfully.
|
||||
@@ -83,7 +83,7 @@ struct ToolInfo {
|
||||
|
||||
/// A thin wrapper around a set of running [`McpClient`] instances.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct McpConnectionManager {
|
||||
pub struct McpConnectionManager {
|
||||
/// Server-name -> client instance.
|
||||
///
|
||||
/// The server name originates from the keys of the `mcp_servers` map in
|
||||
@@ -115,6 +115,10 @@ impl McpConnectionManager {
|
||||
let mut join_set = JoinSet::new();
|
||||
let mut errors = ClientStartErrors::new();
|
||||
|
||||
// Keep a lookup of per-server startup timeouts so we can also apply it to
|
||||
// the initial `tools/list` step below.
|
||||
let mut per_server_timeout: HashMap<String, Duration> = HashMap::new();
|
||||
|
||||
for (server_name, cfg) in mcp_servers {
|
||||
// Validate server name before spawning
|
||||
if !is_valid_mcp_server_name(&server_name) {
|
||||
@@ -126,8 +130,14 @@ impl McpConnectionManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
let timeout = cfg
|
||||
.startup_timeout_ms
|
||||
.map(Duration::from_millis)
|
||||
.unwrap_or(DEFAULT_STARTUP_TIMEOUT);
|
||||
per_server_timeout.insert(server_name.clone(), timeout);
|
||||
|
||||
join_set.spawn(async move {
|
||||
let McpServerConfig { command, args, env } = cfg;
|
||||
let McpServerConfig { command, args, env, .. } = cfg;
|
||||
let client_res = McpClient::new_stdio_client(
|
||||
command.into(),
|
||||
args.into_iter().map(OsString::from).collect(),
|
||||
@@ -158,7 +168,7 @@ impl McpConnectionManager {
|
||||
protocol_version: mcp_types::MCP_SCHEMA_VERSION.to_owned(),
|
||||
};
|
||||
let initialize_notification_params = None;
|
||||
let timeout = Some(Duration::from_secs(10));
|
||||
let timeout = Some(timeout);
|
||||
match client
|
||||
.initialize(params, initialize_notification_params, timeout)
|
||||
.await
|
||||
@@ -188,7 +198,16 @@ impl McpConnectionManager {
|
||||
}
|
||||
}
|
||||
|
||||
let all_tools = list_all_tools(&clients).await?;
|
||||
// Query tools from each server. Do not fail the entire manager if a
|
||||
// server fails to list tools within its startup timeout; instead,
|
||||
// record the error and continue.
|
||||
let (all_tools, list_errors) = list_all_tools(&clients, &per_server_timeout).await;
|
||||
|
||||
// Remove clients that failed to list tools so they are not used later.
|
||||
for (server_name, err) in list_errors {
|
||||
errors.insert(server_name.clone(), err);
|
||||
clients.remove(&server_name);
|
||||
}
|
||||
|
||||
let tools = qualify_tools(all_tools);
|
||||
|
||||
@@ -235,7 +254,8 @@ impl McpConnectionManager {
|
||||
/// contains **all** tools. Each key is the fully-qualified name for the tool.
|
||||
async fn list_all_tools(
|
||||
clients: &HashMap<String, std::sync::Arc<McpClient>>,
|
||||
) -> Result<Vec<ToolInfo>> {
|
||||
timeouts: &HashMap<String, Duration>,
|
||||
) -> (Vec<ToolInfo>, HashMap<String, anyhow::Error>) {
|
||||
let mut join_set = JoinSet::new();
|
||||
|
||||
// Spawn one task per server so we can query them concurrently. This
|
||||
@@ -244,27 +264,40 @@ async fn list_all_tools(
|
||||
for (server_name, client) in clients {
|
||||
let server_name_cloned = server_name.clone();
|
||||
let client_clone = client.clone();
|
||||
let timeout = timeouts
|
||||
.get(server_name)
|
||||
.copied()
|
||||
.unwrap_or(DEFAULT_STARTUP_TIMEOUT);
|
||||
join_set.spawn(async move {
|
||||
let res = client_clone
|
||||
.list_tools(None, Some(LIST_TOOLS_TIMEOUT))
|
||||
.list_tools(None, Some(timeout))
|
||||
.await;
|
||||
(server_name_cloned, res)
|
||||
});
|
||||
}
|
||||
|
||||
let mut aggregated: Vec<ToolInfo> = Vec::with_capacity(join_set.len());
|
||||
let mut errors: HashMap<String, anyhow::Error> = HashMap::new();
|
||||
|
||||
while let Some(join_res) = join_set.join_next().await {
|
||||
let (server_name, list_result) = join_res?;
|
||||
let list_result = list_result?;
|
||||
|
||||
for tool in list_result.tools {
|
||||
let tool_info = ToolInfo {
|
||||
server_name: server_name.clone(),
|
||||
tool_name: tool.name.clone(),
|
||||
tool,
|
||||
};
|
||||
aggregated.push(tool_info);
|
||||
match join_res {
|
||||
Ok((server_name, Ok(list_result))) => {
|
||||
for tool in list_result.tools {
|
||||
let tool_info = ToolInfo {
|
||||
server_name: server_name.clone(),
|
||||
tool_name: tool.name.clone(),
|
||||
tool,
|
||||
};
|
||||
aggregated.push(tool_info);
|
||||
}
|
||||
}
|
||||
Ok((server_name, Err(e))) => {
|
||||
errors.insert(server_name, e);
|
||||
}
|
||||
Err(e) => {
|
||||
// JoinError – associate with an unknown server; log and continue.
|
||||
warn!("failed to join list_tools task: {e:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,7 +307,7 @@ async fn list_all_tools(
|
||||
clients.len()
|
||||
);
|
||||
|
||||
Ok(aggregated)
|
||||
(aggregated, errors)
|
||||
}
|
||||
|
||||
fn is_valid_mcp_server_name(server_name: &str) -> bool {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_core::config_types::McpServerConfig;
|
||||
use codex_core::mcp_connection_manager::McpConnectionManager;
|
||||
|
||||
fn server_bin_path() -> PathBuf {
|
||||
// Compute workspace root from this crate dir
|
||||
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
|
||||
let mut root = PathBuf::from(manifest_dir);
|
||||
// codex-rs/core -> codex-rs
|
||||
root.pop();
|
||||
// prefer debug profile location for tests
|
||||
let mut p = root;
|
||||
p.push("target");
|
||||
p.push("debug");
|
||||
#[cfg(windows)]
|
||||
p.push("codex-mcp-test-server.exe");
|
||||
#[cfg(not(windows))]
|
||||
p.push("codex-mcp-test-server");
|
||||
p
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_manager_skips_slow_server_on_timeout() {
|
||||
// Ensure test server binary exists
|
||||
let server = server_bin_path();
|
||||
assert!(server.exists(), "expected test server at {}", server.display());
|
||||
|
||||
// Slow server exceeds timeout (init/list 200ms vs 100ms timeout)
|
||||
let slow_cfg = McpServerConfig {
|
||||
command: "bash".to_string(),
|
||||
args: vec![
|
||||
"-lc".to_string(),
|
||||
format!(
|
||||
"SLOW_INIT_MS=200 SLOW_LIST_MS=200 {}",
|
||||
server.display()
|
||||
),
|
||||
],
|
||||
env: None,
|
||||
startup_timeout_ms: Some(100),
|
||||
};
|
||||
// Fast server responds immediately
|
||||
let fast_cfg = McpServerConfig {
|
||||
command: server.to_string_lossy().to_string(),
|
||||
args: vec![],
|
||||
env: None,
|
||||
startup_timeout_ms: Some(500),
|
||||
};
|
||||
|
||||
let mut servers = HashMap::new();
|
||||
servers.insert("slow".to_string(), slow_cfg);
|
||||
servers.insert("fast".to_string(), fast_cfg);
|
||||
|
||||
let (mgr, errs) = McpConnectionManager::new(servers)
|
||||
.await
|
||||
.expect("manager creation should not fail entirely");
|
||||
|
||||
// Slow should be reported as error; fast should be available.
|
||||
assert!(errs.contains_key("slow"));
|
||||
assert!(!errs.contains_key("fast"));
|
||||
|
||||
let tools = mgr.list_all_tools();
|
||||
// Expect tool echo from fast server only: qualified name fast__echo
|
||||
assert!(tools.keys().any(|k| k.starts_with("fast__")));
|
||||
assert!(!tools.keys().any(|k| k.starts_with("slow__")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_manager_respects_extended_startup_timeout() {
|
||||
// Ensure test server binary exists
|
||||
let server = server_bin_path();
|
||||
assert!(server.exists(), "expected test server at {}", server.display());
|
||||
|
||||
// Slow server within extended timeout (init/list 200ms vs 500ms)
|
||||
let slow_ok = McpServerConfig {
|
||||
command: "bash".to_string(),
|
||||
args: vec![
|
||||
"-lc".to_string(),
|
||||
format!(
|
||||
"SLOW_INIT_MS=200 SLOW_LIST_MS=200 {}",
|
||||
server.display()
|
||||
),
|
||||
],
|
||||
env: None,
|
||||
startup_timeout_ms: Some(500),
|
||||
};
|
||||
let mut servers = HashMap::new();
|
||||
servers.insert("slow_ok".to_string(), slow_ok);
|
||||
|
||||
let (mgr, errs) = McpConnectionManager::new(servers)
|
||||
.await
|
||||
.expect("manager creation should not fail");
|
||||
|
||||
assert!(errs.is_empty(), "no errors expected, got: {errs:?}");
|
||||
let tools = mgr.list_all_tools();
|
||||
assert!(tools.keys().any(|k| k.starts_with("slow_ok__")));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "codex-mcp-smoke"
|
||||
version = { workspace = true }
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
codex-core = { path = "../core" }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
@@ -0,0 +1,79 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_core::config_types::McpServerConfig;
|
||||
use codex_core::mcp_connection_manager::McpConnectionManager;
|
||||
|
||||
fn server_bin_path() -> PathBuf {
|
||||
let mut root = std::env::current_dir().unwrap();
|
||||
// Ensure we run from workspace root `codex-rs` for relative path to work.
|
||||
if root.ends_with("codex-rs") {
|
||||
// ok
|
||||
} else if root.ends_with("code") {
|
||||
root.push("codex-rs");
|
||||
}
|
||||
let mut p = root;
|
||||
p.push("target");
|
||||
p.push("debug");
|
||||
#[cfg(windows)]
|
||||
p.push("codex-mcp-test-server.exe");
|
||||
#[cfg(not(windows))]
|
||||
p.push("codex-mcp-test-server");
|
||||
p
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let server = server_bin_path();
|
||||
if !server.exists() {
|
||||
eprintln!("Build the test server first: cargo build -p codex-mcp-test-server");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
// Fast server
|
||||
let fast = McpServerConfig {
|
||||
command: server.to_string_lossy().to_string(),
|
||||
args: vec![],
|
||||
env: None,
|
||||
startup_timeout_ms: Some(500),
|
||||
};
|
||||
// Slow-one: 2s but we allow 3s
|
||||
let slow_ok = McpServerConfig {
|
||||
command: "bash".to_string(),
|
||||
args: vec![
|
||||
"-lc".to_string(),
|
||||
format!("SLOW_INIT_MS=500 SLOW_LIST_MS=2000 {}", server.display()),
|
||||
],
|
||||
env: None,
|
||||
startup_timeout_ms: Some(3000),
|
||||
};
|
||||
// Slow-two: 3s but we allow 1s (should fail)
|
||||
let slow_fail = McpServerConfig {
|
||||
command: "bash".to_string(),
|
||||
args: vec![
|
||||
"-lc".to_string(),
|
||||
format!("SLOW_INIT_MS=500 SLOW_LIST_MS=3000 {}", server.display()),
|
||||
],
|
||||
env: None,
|
||||
startup_timeout_ms: Some(1000),
|
||||
};
|
||||
|
||||
let mut servers = HashMap::new();
|
||||
servers.insert("fast".to_string(), fast);
|
||||
servers.insert("slow_ok".to_string(), slow_ok);
|
||||
servers.insert("slow_fail".to_string(), slow_fail);
|
||||
|
||||
let (mgr, errs) = McpConnectionManager::new(servers).await?;
|
||||
println!("Errors: {}", errs.len());
|
||||
for (name, e) in &errs {
|
||||
println!(" {}: {}", name, e);
|
||||
}
|
||||
let tools = mgr.list_all_tools();
|
||||
println!("Tools ({}):", tools.len());
|
||||
for k in tools.keys() {
|
||||
println!(" {}", k);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "codex-mcp-test-server"
|
||||
version = { workspace = true }
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
use std::io::{self, BufRead, Write};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Id {
|
||||
Int(i64),
|
||||
Str(String),
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct JsonRpcReq {
|
||||
jsonrpc: String,
|
||||
method: String,
|
||||
#[allow(dead_code)]
|
||||
params: Option<serde_json::Value>,
|
||||
id: Option<Id>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct JsonRpcResp {
|
||||
jsonrpc: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<Id>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
fn sleep_env_ms(var: &str) {
|
||||
if let Ok(v) = env::var(var) {
|
||||
if let Ok(ms) = v.parse::<u64>() {
|
||||
thread::sleep(Duration::from_millis(ms));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let stdin = io::stdin();
|
||||
let mut stdout = io::stdout();
|
||||
for line in stdin.lock().lines() {
|
||||
let line = match line { Ok(l) => l, Err(_) => break };
|
||||
let Ok(req) = serde_json::from_str::<JsonRpcReq>(&line) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match req.method.as_str() {
|
||||
"initialize" => {
|
||||
sleep_env_ms("SLOW_INIT_MS");
|
||||
let result = json!({
|
||||
"capabilities": { "tools": { "listChanged": true } },
|
||||
"serverInfo": { "name": "codex-mcp-test-server", "version": "0.0.1" },
|
||||
"protocolVersion": "2025-06-18"
|
||||
});
|
||||
let resp = JsonRpcResp { jsonrpc: "2.0".into(), id: req.id, result: Some(result), error: None };
|
||||
let _ = writeln!(stdout, "{}", serde_json::to_string(&resp).unwrap());
|
||||
let _ = stdout.flush();
|
||||
}
|
||||
"notifications/initialized" => {
|
||||
// No-op
|
||||
}
|
||||
"tools/list" => {
|
||||
sleep_env_ms("SLOW_LIST_MS");
|
||||
let tools = json!({
|
||||
"tools": [
|
||||
{
|
||||
"name": "echo",
|
||||
"description": "Echo back provided text",
|
||||
"inputSchema": { "type": "object", "properties": { "text": { "type": "string" } }, "required": ["text"] }
|
||||
}
|
||||
]
|
||||
});
|
||||
let resp = JsonRpcResp { jsonrpc: "2.0".into(), id: req.id, result: Some(tools), error: None };
|
||||
let _ = writeln!(stdout, "{}", serde_json::to_string(&resp).unwrap());
|
||||
let _ = stdout.flush();
|
||||
}
|
||||
_ => {
|
||||
// Unknown method -> echo minimal error structure
|
||||
let err = json!({ "code": -32601, "message": "Method not found" });
|
||||
let resp = JsonRpcResp { jsonrpc: "2.0".into(), id: req.id, result: None, error: Some(err) };
|
||||
let _ = writeln!(stdout, "{}", serde_json::to_string(&resp).unwrap());
|
||||
let _ = stdout.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7479,6 +7479,7 @@ impl ChatWidget<'_> {
|
||||
} else {
|
||||
Some(env.clone())
|
||||
},
|
||||
startup_timeout_ms: None,
|
||||
};
|
||||
match codex_core::config::add_mcp_server(&home, &name, cfg.clone()) {
|
||||
Ok(()) => {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
param(
|
||||
[string]$Version = "latest",
|
||||
[switch]$FixCache
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Step($msg) { Write-Host ("---- " + $msg) -ForegroundColor Cyan }
|
||||
function Info($msg) { Write-Host $msg }
|
||||
function Warn($msg) { Write-Host $msg -ForegroundColor Yellow }
|
||||
function Err ($msg) { Write-Host $msg -ForegroundColor Red }
|
||||
|
||||
# 1) Basics
|
||||
Step "Environment"
|
||||
$envs = [ordered]@{
|
||||
"Node" = (node -v 2>$null)
|
||||
"npm" = (npm -v 2>$null)
|
||||
"PowerShell" = $PSVersionTable.PSVersion.ToString()
|
||||
"OSArch" = $env:PROCESSOR_ARCHITECTURE
|
||||
"APPDATA" = $env:APPDATA
|
||||
"LOCALAPPDATA" = $env:LOCALAPPDATA
|
||||
}
|
||||
$envs.GetEnumerator() | ForEach-Object { Info ("{0} = {1}" -f $_.Key, $_.Value) }
|
||||
|
||||
Step "npm config (registry/proxy/flags)"
|
||||
try {
|
||||
Info ("registry = " + (npm config get registry))
|
||||
Info ("ignore-scripts = " + (npm config get ignore-scripts))
|
||||
Info ("strict-ssl = " + (npm config get strict-ssl))
|
||||
$hp = npm config get https-proxy; if ($hp -ne "null") { Info ("https-proxy = " + $hp) }
|
||||
$p = npm config get proxy; if ($p -ne "null") { Info ("proxy = " + $p) }
|
||||
} catch { Warn ("npm config read failed: " + $_) }
|
||||
|
||||
# 2) Resolve versions and tarballs from NPM
|
||||
Step "Resolve @just-every/code@$Version (npm)"
|
||||
$mainVersion = $null
|
||||
try { $mainVersion = (npm view "@just-every/code@$Version" version) } catch {}
|
||||
if (-not $mainVersion) { Err "Could not resolve @just-every/code@$Version"; exit 1 }
|
||||
$mainTar = $null
|
||||
try { $mainTar = (npm view "@just-every/code@$mainVersion" dist.tarball) } catch {}
|
||||
Info ("main version = " + $mainVersion)
|
||||
Info ("main tarball = " + ($mainTar ?? "<none>"))
|
||||
|
||||
Step "Resolve @just-every/code-win32-x64@$mainVersion (npm)"
|
||||
$winVersion = $null; $winTar = $null
|
||||
try { $winVersion = (npm view "@just-every/code-win32-x64@$mainVersion" version) } catch {}
|
||||
if ($winVersion) {
|
||||
try { $winTar = (npm view "@just-every/code-win32-x64@$mainVersion" dist.tarball) } catch {}
|
||||
Info ("win version = " + $winVersion)
|
||||
Info ("win tarball = " + ($winTar ?? "<none>"))
|
||||
} else {
|
||||
Warn ("@just-every/code-win32-x64@$mainVersion not found on npm")
|
||||
}
|
||||
|
||||
# 3) Download + inspect npm tarballs
|
||||
$tmp = New-Item -ItemType Directory -Path (Join-Path $env:TEMP ("code-diag-" + [guid]::NewGuid().ToString("N"))) -Force
|
||||
Step ("Work dir: " + $tmp.FullName)
|
||||
Set-Location $tmp.FullName
|
||||
|
||||
function Fetch($url, $dst) {
|
||||
if (-not $url) { return $false }
|
||||
try {
|
||||
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $dst -TimeoutSec 120
|
||||
return $true
|
||||
} catch {
|
||||
Err ("Download failed: " + $url)
|
||||
Err ($_.Exception.Message)
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
$t1 = Join-Path $pwd "code.tgz"
|
||||
$t2 = Join-Path $pwd "win.tgz"
|
||||
$gotMain = Fetch $mainTar $t1
|
||||
$gotWin = $false
|
||||
if ($winTar) { $gotWin = Fetch $winTar $t2 }
|
||||
|
||||
if ($gotMain) {
|
||||
try {
|
||||
mkdir p1 | Out-Null
|
||||
tar -xzf $t1 -C p1
|
||||
Step ("@just-every/code@" + $mainVersion + " contents")
|
||||
dir p1/package
|
||||
Info "\nHead of bin/coder.js:"
|
||||
Get-Content p1/package/bin/coder.js -TotalCount 30 | ForEach-Object { " " + $_ }
|
||||
$isESM = Select-String -Path p1/package/package.json -Pattern '"type"\s*:\s*"module"' -Quiet
|
||||
Info ("package.json type=module? " + [string]$isESM)
|
||||
} catch {
|
||||
Warn ("Could not extract or inspect main tarball: " + $_)
|
||||
}
|
||||
} else {
|
||||
Err "Could not fetch main tarball from npm"
|
||||
}
|
||||
|
||||
if ($gotWin) {
|
||||
try {
|
||||
mkdir p2 | Out-Null
|
||||
tar -xzf $t2 -C p2
|
||||
Step ("@just-every/code-win32-x64@" + $mainVersion + " contents")
|
||||
dir p2/package/bin
|
||||
} catch {
|
||||
Warn ("Could not extract or inspect win tarball: " + $_)
|
||||
}
|
||||
}
|
||||
|
||||
# 4) Compute target triple and cache paths
|
||||
$triple = "x86_64-pc-windows-msvc.exe" # Windows x64
|
||||
$cacheDir = Join-Path $env:LOCALAPPDATA ("just-every\code\" + $mainVersion)
|
||||
$expectOK = Join-Path $cacheDir ("code-" + $triple) # correct
|
||||
$expectBAD = $expectOK + ".exe" # incorrect (double .exe)
|
||||
Step "Cache expectations"
|
||||
Info ("cache dir = " + $cacheDir)
|
||||
Info ("expect OK = " + $expectOK)
|
||||
Info ("expect BAD = " + $expectBAD)
|
||||
Info ("exists OK? " + ([string](Test-Path $expectOK)))
|
||||
Info ("exists BAD? " + ([string](Test-Path $expectBAD)))
|
||||
|
||||
# 5) GitHub release asset (fallback path)
|
||||
$ghZip = "https://github.com/just-every/code/releases/download/v$mainVersion/code-$triple.zip"
|
||||
Step "GitHub asset (fallback) HEAD check"
|
||||
try {
|
||||
$resp = Invoke-WebRequest -UseBasicParsing -Uri $ghZip -Method Head
|
||||
Info ("GitHub asset reachable? " + [string]$resp.StatusCode)
|
||||
} catch {
|
||||
Warn ("GitHub HEAD failed: " + $_)
|
||||
}
|
||||
|
||||
# Optional: download and test unzip to cache
|
||||
$zipTmp = Join-Path $pwd ("code-" + $triple + ".zip")
|
||||
$unzipOk = $false
|
||||
$existedBefore = Test-Path $expectOK
|
||||
try {
|
||||
if (Fetch $ghZip $zipTmp) {
|
||||
Step "Test Expand-Archive to cache dir"
|
||||
if (-not (Test-Path $cacheDir)) { New-Item -ItemType Directory -Path $cacheDir | Out-Null }
|
||||
$sysRoot = $env:SystemRoot
|
||||
$psFull = Join-Path $sysRoot "System32\WindowsPowerShell\v1.0\powershell.exe"
|
||||
$psCmd = "Expand-Archive -Path `"$zipTmp`" -DestinationPath `"$cacheDir`" -Force"
|
||||
$ok = $false
|
||||
try { & "$psFull" -NoProfile -NonInteractive -Command $psCmd 2>$null; $ok = $true } catch {}
|
||||
if (-not $ok) { try { powershell -NoProfile -NonInteractive -Command $psCmd 2>$null; $ok = $true } catch {} }
|
||||
if (-not $ok) { try { pwsh -NoProfile -NonInteractive -Command $psCmd 2>$null; $ok = $true } catch {} }
|
||||
if (-not $ok) { try { tar -xf $zipTmp -C $cacheDir; $ok = $true } catch {} }
|
||||
$unzipOk = $ok
|
||||
Info ("unzip ok? " + [string]$unzipOk)
|
||||
Info ("post-unzip OK exists? " + ([string](Test-Path $expectOK)))
|
||||
Info ("post-unzip BAD exists? " + ([string](Test-Path $expectBAD)))
|
||||
}
|
||||
} catch {
|
||||
Warn ("Unzip test failed: " + $_)
|
||||
} finally {
|
||||
try { Remove-Item -Force $zipTmp } catch {}
|
||||
if (-not $FixCache -and -not $existedBefore) {
|
||||
try { if (Test-Path $expectOK) { Remove-Item -Force $expectOK } } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
# 6) Global locations that the error messages referenced
|
||||
Step "Global npm prefix + expected node_modules bin path"
|
||||
$prefix = $null
|
||||
try { $prefix = (npm prefix -g 2>$null) } catch {}
|
||||
$nmBin = if ($prefix) { Join-Path $prefix "node_modules\@just-every\code\bin" } else { "<unknown>" }
|
||||
Info ("prefix = " + ($prefix ?? "<none>"))
|
||||
Info ("bin dir= " + $nmBin)
|
||||
if ($prefix -and (Test-Path $nmBin)) { dir $nmBin } else { Info "(bin dir not present - expected for npx, present for global installs)" }
|
||||
|
||||
# 7) Summary
|
||||
Step "RESULTS SUMMARY"
|
||||
$lines = @()
|
||||
$lines += ("main version: " + $mainVersion)
|
||||
$lines += ("platform pkg present on npm: " + ([string]([bool]$winVersion)))
|
||||
$lines += ("cache ok path exists: " + ([string](Test-Path $expectOK)))
|
||||
$lines += ("cache bad path exists: " + ([string](Test-Path $expectBAD)))
|
||||
$lines += ("github asset unzip ok: " + ([string]$unzipOk))
|
||||
$esm = $false; try { $esm = Select-String -Path p1/package/package.json -Pattern '"type"\s*:\s*"module"' -Quiet } catch {}
|
||||
$lines += ("esm bin (type:module) in npm tarball: " + ([string]$esm))
|
||||
$lines | ForEach-Object { Info $_ }
|
||||
|
||||
Info ""
|
||||
Info "Done. Paste the RESULTS SUMMARY back if anything looks off."
|
||||
|
||||
Reference in New Issue
Block a user