Skip to content

Commit 5e77d88

Browse files
rbenzingclaude
andcommitted
Auto-rotate occupied ports and fix Windows-only Apache/PHP issues
When the configured port for a service is in use, scan upward (up to +20) for the next free one and bind there. Apache's httpd.conf and MySQL's my.ini are regenerated each spawn so the actual bind matches what RAMP probes for readiness; PHP's port is passed via CLI flag. The chosen port is surfaced in the UI (yellow when remapped) and ramp.toml is left untouched so user preferences persist across launches. Along the way, fixed several latent issues that were combining with the port-conflict path to produce a crash loop: - check_port_available now uses TcpStream::connect rather than bind, so TIME_WAIT zombies after a crash loop don't show as false conflicts. - ProcessExit transitions now emit KillService so orphaned processes from a readiness timeout don't accumulate across retries. - Apache readiness probes /__ramp_health (a deliberate 404) instead of / so the check doesn't hang behind the FastCGI proxy when PHP is still warming up; any Apache Server header counts as ready. - httpd.conf's PHP handler switched from <FilesMatch>+SetHandler to ProxyPassMatch with explicit script path — the SetHandler form produces a malformed proxy URL on Windows ("fcgi://host:9000c:/...") that mod_proxy_fcgi parses as a bad hostname. - php.ini sets doc_root + cgi.fix_pathinfo so PHP-CGI resolves scripts relative to htdocs without absolute drive-letter paths in SCRIPT_FILENAME. - fatal() now shows a MessageBoxW dialog so failures are visible under windows_subsystem = "windows" instead of silently exiting. - MySQL init non-fatal: surfaced via mysql.last_error in the UI; initialize_mysql wipes any leftovers in data_dir before running --initialize-insecure so a partially-initialized dir self-heals. - Apache config drops mod_unixd (POSIX-only) and the deprecated BindAddress; mod_authn_core added. - Long error messages truncated to 40 chars in the UI with the full text on hover so they don't push other rows' buttons off-screen. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 28feead commit 5e77d88

11 files changed

Lines changed: 291 additions & 69 deletions

File tree

src/apache_conf.rs

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ use crate::state::RampConfig;
33
/// Generate a minimal httpd.conf for RAMP's bundled Apache layout.
44
/// Only called when the file does not already exist (never overwrites user edits).
55
pub fn generate_httpd_conf(cfg: &RampConfig) -> String {
6+
generate_httpd_conf_with_ports(cfg, cfg.apache.port, cfg.php.port)
7+
}
8+
9+
/// Same as `generate_httpd_conf` but with explicit port overrides — used by the
10+
/// executor when the configured port was occupied and a different one was chosen.
11+
pub fn generate_httpd_conf_with_ports(cfg: &RampConfig, port: u16, php_port: u16) -> String {
612
let apache_dir = cfg.install_dir.join("apache");
713
let apache_dir = apache_dir.display().to_string().replace('\\', "/");
814

@@ -58,10 +64,20 @@ DocumentRoot "{apache_dir}/htdocs"
5864
DirectoryIndex index.php index.html index.htm
5965
</IfModule>
6066
61-
# Proxy .php requests to PHP-CGI FastCGI listener
62-
<FilesMatch "\.php$">
63-
SetHandler "proxy:fcgi://127.0.0.1:{php_port}"
64-
</FilesMatch>
67+
# Proxy .php requests to PHP-CGI FastCGI listener.
68+
#
69+
# Why this is the way it is:
70+
# - The naive `<FilesMatch> + SetHandler "proxy:fcgi://..."` pattern breaks on
71+
# Windows because mod_proxy_fcgi appends the script's drive-lettered path
72+
# directly to the proxy URL ("fcgi://host:9000c:/..."), which the URL parser
73+
# sees as a malformed authority → "DNS lookup failure".
74+
# - ProxyPassMatch with an explicit script-path target avoids the URL parsing
75+
# bug. The capture group `$1` holds the relative script path; PHP-CGI then
76+
# joins it against `doc_root` from php.ini to find the real file. We rely on
77+
# `doc_root` (set in php.ini) rather than absolute paths in the proxy URL,
78+
# because absolute Windows paths in fcgi:// URLs trigger the same parser bug.
79+
ProxyFCGIBackendType GENERIC
80+
ProxyPassMatch "^/(.+\.php(/.*)?)$" "fcgi://127.0.0.1:{php_port}/$1"
6581
6682
# Deny .htaccess and .htpasswd access
6783
<Files ".ht*">
@@ -81,13 +97,25 @@ LogLevel warn
8197
AddType application/x-compress .Z
8298
AddType application/x-gzip .gz .tgz
8399
</IfModule>
84-
"#,
85-
apache_dir = apache_dir,
86-
port = cfg.apache.port,
87-
php_port = cfg.php.port,
100+
"#
88101
)
89102
}
90103

104+
/// Force-rewrite httpd.conf with explicit port overrides. Used when the executor
105+
/// has resolved Apache or PHP to a different port than the configured one.
106+
pub fn rewrite_httpd_conf_with_ports(
107+
cfg: &RampConfig,
108+
port: u16,
109+
php_port: u16,
110+
) -> Result<(), String> {
111+
let conf_path = &cfg.apache.conf;
112+
let dir = conf_path.parent().ok_or("httpd.conf has no parent dir")?;
113+
std::fs::create_dir_all(dir).map_err(|e| format!("cannot create apache/conf dir: {e}"))?;
114+
let content = generate_httpd_conf_with_ports(cfg, port, php_port);
115+
crate::config::atomic_write(conf_path, content.as_bytes())
116+
.map_err(|e| format!("cannot rewrite httpd.conf: {e}"))
117+
}
118+
91119
/// Write httpd.conf only if it doesn't already exist.
92120
pub fn ensure_httpd_conf(cfg: &RampConfig) -> Result<(), String> {
93121
let conf_path = &cfg.apache.conf;
@@ -158,7 +186,8 @@ mod tests {
158186
let tmp = TempDir::new().unwrap();
159187
let cfg = test_cfg(tmp.path());
160188
let conf = generate_httpd_conf(&cfg);
161-
assert!(conf.contains("proxy:fcgi://127.0.0.1:9000"));
189+
assert!(conf.contains("fcgi://127.0.0.1:9000"));
190+
assert!(conf.contains("ProxyPassMatch"));
162191
assert!(conf.contains("mod_proxy_fcgi.so"));
163192
}
164193

src/events.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ pub enum Event {
2828

2929
// Port management
3030
PortConflictDetected(Service),
31+
/// Executor resolved the port the service will actually bind to.
32+
/// Emitted before a successful spawn so the UI/reducer know the chosen port.
33+
PortAssigned {
34+
service: Service,
35+
port: u16,
36+
},
3137

3238
// Config — boxed to keep the enum variant size uniform
3339
ConfigReloaded(Box<crate::state::RampConfig>),

src/executor.rs

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
use crate::apache_conf::rewrite_httpd_conf_with_ports;
12
use crate::config::atomic_write;
23
use crate::events::{Event, SideEffect};
34
use crate::health::{poll_until_ready, run_health_checker};
45
use crate::logger::SharedLog;
5-
use crate::process::{check_port_available, spawn_service, ServiceProcess};
6-
use crate::state::{AppState, PersistedState, RampConfig, Service};
6+
use crate::mysql_conf::rewrite_my_ini_with_port;
7+
use crate::process::{find_available_port, spawn_service, ServiceProcess};
8+
use crate::state::{AppState, PersistedState, RampConfig, Service, PORT_SCAN_RANGE};
79
use crossbeam_channel::Sender;
810
use std::collections::HashMap;
911

@@ -27,6 +29,10 @@ pub struct Executor {
2729
tx: Sender<Event>,
2830
log: SharedLog,
2931
handles: HashMap<Service, ServiceHandles>,
32+
/// Effective port per service — set when do_spawn resolves a free port.
33+
/// Used by readiness/health checks and by config regen for cross-service ports
34+
/// (e.g. Apache's httpd.conf needs PHP's effective port for the FastCGI proxy).
35+
effective_ports: HashMap<Service, u16>,
3036
}
3137

3238
impl Executor {
@@ -36,9 +42,18 @@ impl Executor {
3642
tx,
3743
log,
3844
handles: HashMap::new(),
45+
effective_ports: HashMap::new(),
3946
}
4047
}
4148

49+
/// Effective port to bind/probe — falls back to the configured port until a spawn resolves one.
50+
fn effective_port(&self, svc: Service) -> u16 {
51+
self.effective_ports
52+
.get(&svc)
53+
.copied()
54+
.unwrap_or_else(|| self.port(svc))
55+
}
56+
4257
pub fn execute(&mut self, effects: Vec<SideEffect>, state: &AppState) {
4358
for effect in effects {
4459
match effect {
@@ -61,7 +76,7 @@ impl Executor {
6176
/// Start health checks for a service that just became Running.
6277
pub fn start_health_check(&mut self, svc: Service) {
6378
self.do_stop_health(svc);
64-
let port = self.port(svc);
79+
let port = self.effective_port(svc);
6580
let (stop_tx, stop_rx) = crossbeam_channel::bounded(1);
6681
let entry = self.handles.entry(svc).or_insert_with(|| {
6782
let (kill_tx, _) = crossbeam_channel::bounded(1);
@@ -79,20 +94,63 @@ impl Executor {
7994
}
8095

8196
fn do_spawn(&mut self, svc: Service) {
82-
let port = self.port(svc);
97+
let configured = self.port(svc);
8398

84-
// Pre-check port
85-
if !check_port_available(port) {
86-
let _ = self.tx.send(Event::PortConflictDetected(svc));
99+
// Scan upward for a free port. None → every port in the range is occupied.
100+
let chosen = match find_available_port(configured, PORT_SCAN_RANGE) {
101+
Some(p) => p,
102+
None => {
103+
let _ = self.tx.send(Event::PortConflictDetected(svc));
104+
return;
105+
}
106+
};
107+
108+
// Always regenerate the service config file from the chosen port. This is
109+
// critical: skipping the rewrite when chosen == configured leaves any stale
110+
// config on disk (e.g. Listen 127.0.0.1:8081 from a previous crash-loop run)
111+
// pointing at a different port than the one we'll probe for readiness, which
112+
// turns into a phantom crash loop. PHP doesn't need a config rewrite — its
113+
// port is a CLI flag.
114+
let result = match svc {
115+
Service::Apache => {
116+
let php_port = self.effective_port(Service::Php);
117+
rewrite_httpd_conf_with_ports(&self.config, chosen, php_port)
118+
}
119+
Service::Mysql => rewrite_my_ini_with_port(&self.config, chosen),
120+
Service::Php => Ok(()),
121+
};
122+
if let Err(reason) = result {
123+
let _ = self.tx.send(Event::ProcessSpawnFailed {
124+
service: svc,
125+
reason: format!("config regen for port {chosen}: {reason}"),
126+
});
87127
return;
88128
}
89129

130+
// If this is PHP and Apache is currently running, refresh httpd.conf so the
131+
// FastCGI proxy points at PHP's chosen port. Apache stays on its current port.
132+
if svc == Service::Php && self.handles.contains_key(&Service::Apache) {
133+
let apache_port = self.effective_port(Service::Apache);
134+
if let Err(reason) = rewrite_httpd_conf_with_ports(&self.config, apache_port, chosen) {
135+
self.log.push(format!(
136+
"warn: could not refresh httpd.conf for new PHP port: {reason}"
137+
));
138+
}
139+
}
140+
141+
// Record the resolution before spawn so later emits/queries see it.
142+
self.effective_ports.insert(svc, chosen);
143+
let _ = self.tx.send(Event::PortAssigned {
144+
service: svc,
145+
port: chosen,
146+
});
147+
90148
// Kill any existing handles for this service
91149
self.do_kill(svc);
92150

93151
let (kill_tx, kill_rx) = crossbeam_channel::bounded::<()>(1);
94152

95-
match spawn_service(svc, &self.config, self.tx.clone()) {
153+
match spawn_service(svc, &self.config, chosen, self.tx.clone()) {
96154
Ok(proc) => {
97155
let tx = self.tx.clone();
98156
let join = std::thread::spawn(move || watcher(proc, tx, kill_rx));
@@ -131,7 +189,7 @@ impl Executor {
131189
}
132190

133191
fn do_readiness_check(&self, svc: Service) {
134-
let port = self.port(svc);
192+
let port = self.effective_port(svc);
135193
let tx = self.tx.clone();
136194
std::thread::spawn(move || poll_until_ready(svc, port, tx));
137195
}

src/health.rs

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,29 @@ use std::io::Read;
77
use std::net::TcpStream;
88
use std::time::{Duration, Instant};
99

10-
/// Check if Apache is ready: TCP connect + HTTP 200–399 + Apache signature.
10+
/// Check if Apache is ready.
11+
///
12+
/// Probes a deliberately-nonexistent path so Apache returns 404 from its own error
13+
/// handler (with the Server header) without invoking the PHP FastCGI proxy. This
14+
/// avoids hanging the readiness check when PHP-CGI isn't running yet — requesting
15+
/// "/" would route through `mod_proxy_fcgi` and block for the proxy connect timeout.
16+
///
17+
/// Any HTTP response identifying as Apache via the Server header counts as ready.
1118
pub fn check_apache_ready(port: u16) -> bool {
12-
let url = format!("http://127.0.0.1:{port}/");
19+
let url = format!("http://127.0.0.1:{port}/__ramp_health");
1320
match ureq::get(&url).timeout(Duration::from_secs(2)).call() {
14-
Ok(resp) => {
15-
let status = resp.status();
16-
let server = resp.header("Server").unwrap_or("").to_lowercase();
17-
(200..400).contains(&status) && server.contains("apache")
18-
}
21+
Ok(resp) => server_is_apache(resp.header("Server").unwrap_or("")),
22+
// 4xx/5xx responses come back as Err::Status with the underlying response —
23+
// a 404 from Apache is exactly what we want to see here.
24+
Err(ureq::Error::Status(_, resp)) => server_is_apache(resp.header("Server").unwrap_or("")),
1925
Err(_) => false,
2026
}
2127
}
2228

29+
fn server_is_apache(header: &str) -> bool {
30+
header.to_lowercase().contains("apache")
31+
}
32+
2333
/// Check if MySQL is ready: TCP connect + reads MySQL greeting packet prefix.
2434
pub fn check_mysql_ready(port: u16) -> bool {
2535
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));

src/mysql_conf.rs

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,15 @@ use crate::state::RampConfig;
33
/// Generate a minimal my.ini for MySQL 9.x compatible with RAMP's layout.
44
/// Only called when the file does not already exist.
55
pub fn generate_my_ini(cfg: &RampConfig) -> String {
6-
let mysql_dir = cfg.install_dir.join("mysql");
7-
let mysql_dir_s = mysql_dir.display().to_string().replace('\\', "/");
8-
let data_dir_s = cfg.mysql.data_dir.display().to_string().replace('\\', "/");
6+
generate_my_ini_with_port(cfg, cfg.mysql.port)
7+
}
8+
9+
/// Same as `generate_my_ini` but with an explicit port — used by the executor
10+
/// when the configured port was occupied and a different one was chosen.
11+
pub fn generate_my_ini_with_port(cfg: &RampConfig, port: u16) -> String {
12+
let mysql_dir_path = cfg.install_dir.join("mysql");
13+
let mysql_dir = mysql_dir_path.display().to_string().replace('\\', "/");
14+
let data_dir = cfg.mysql.data_dir.display().to_string().replace('\\', "/");
915

1016
format!(
1117
r#"# RAMP — generated my.ini
@@ -33,13 +39,21 @@ sql_mode = ""
3339
[client]
3440
port = {port}
3541
default-character-set = utf8mb4
36-
"#,
37-
mysql_dir = mysql_dir_s,
38-
data_dir = data_dir_s,
39-
port = cfg.mysql.port,
42+
"#
4043
)
4144
}
4245

46+
/// Force-rewrite my.ini with an explicit port override. Used when the executor
47+
/// has resolved MySQL to a different port than the configured one.
48+
pub fn rewrite_my_ini_with_port(cfg: &RampConfig, port: u16) -> Result<(), String> {
49+
let ini_path = &cfg.mysql.ini;
50+
let dir = ini_path.parent().ok_or("my.ini has no parent dir")?;
51+
std::fs::create_dir_all(dir).map_err(|e| format!("cannot create mysql dir: {e}"))?;
52+
let content = generate_my_ini_with_port(cfg, port);
53+
crate::config::atomic_write(ini_path, content.as_bytes())
54+
.map_err(|e| format!("cannot rewrite my.ini: {e}"))
55+
}
56+
4357
/// Write my.ini only if it doesn't already exist.
4458
pub fn ensure_my_ini(cfg: &RampConfig) -> Result<(), String> {
4559
let ini_path = &cfg.mysql.ini;

src/php_conf.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ pub fn generate_php_ini(cfg: &RampConfig) -> String {
77
let php_dir_s = php_dir.display().to_string().replace('\\', "/");
88
let ext_dir = php_dir.join("ext");
99
let ext_dir_s = ext_dir.display().to_string().replace('\\', "/");
10+
let doc_root = cfg
11+
.install_dir
12+
.join("apache")
13+
.join("htdocs")
14+
.display()
15+
.to_string()
16+
.replace('\\', "/");
1017

1118
format!(
1219
r#"; RAMP — generated php.ini
@@ -36,8 +43,11 @@ auto_globals_jit = On
3643
post_max_size = 64M
3744
default_mimetype = "text/html"
3845
default_charset = "UTF-8"
39-
doc_root =
46+
doc_root = "{doc_root}"
4047
user_dir =
48+
cgi.fix_pathinfo = 1
49+
cgi.force_redirect = 0
50+
cgi.discard_path = 0
4151
extension_dir = "{ext_dir}"
4252
enable_dl = Off
4353
file_uploads = On
@@ -128,6 +138,7 @@ session.sid_bits_per_character = 5
128138
"#,
129139
php_dir = php_dir_s,
130140
ext_dir = ext_dir_s,
141+
doc_root = doc_root,
131142
mysql_port = cfg.mysql.port,
132143
)
133144
}

0 commit comments

Comments
 (0)