Skip to content
13 changes: 12 additions & 1 deletion apps/tui-rs/src/runtime_config.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
pub const DEFAULT_SERVER_PORT: u16 = 7_391;
// SERVER_PORT_BASE is duplicated here for the TUI sidebar binary (which does
// not depend on packages/runtime-rs). The canonical definition lives in
// packages/runtime-rs/src/shared.rs and must be kept in sync — both values
// describe the same port range the server binds.
pub const SERVER_PORT_BASE: u32 = 22_000;

pub fn hash_server_key(input: &str) -> u16 {
let mut hash = 0_u32;
Expand All @@ -17,7 +22,13 @@ pub fn resolve_server_port(server_key: Option<u16>, explicit: Option<&str>) -> u
}

match server_key {
Some(key) => 17_000 + key,
Some(key) => u16::try_from(SERVER_PORT_BASE + u32::from(key)).unwrap_or_else(|_| {
eprintln!(
"opensessions: server_key {key} + base {SERVER_PORT_BASE} overflows port range, \
falling back to DEFAULT_SERVER_PORT {DEFAULT_SERVER_PORT}",
);
DEFAULT_SERVER_PORT
}),
None => DEFAULT_SERVER_PORT,
}
}
2 changes: 1 addition & 1 deletion apps/tui-rs/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ fn resolves_tmux_derived_endpoint_from_environment_like_typescript_client() {
});

assert_eq!(endpoint.server_host, "127.0.0.1");
assert_eq!(endpoint.server_port, 36_916);
assert_eq!(endpoint.server_port, 41_916);
}

#[test]
Expand Down
6 changes: 5 additions & 1 deletion apps/tui-rs/tests/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ fn parses_state_with_nested_agent_and_filter() {
fn server_key_hash_matches_typescript_runtime() {
assert_eq!(hash_server_key("/private/tmp/tmux-501/default"), 19_916);
assert_eq!(resolve_server_port(None, None), 7_391);
assert_eq!(resolve_server_port(Some(19_916), None), 36_916);
assert_eq!(resolve_server_port(Some(19_916), None), 41_916);
assert_eq!(resolve_server_port(Some(19_916), Some("8123")), 8_123);
// Overflow fallback: any key that pushes 22000+key above u16::MAX
// must fall back to DEFAULT_SERVER_PORT rather than silently wrap.
assert_eq!(resolve_server_port(Some(50_000), None), 7_391);
assert_eq!(resolve_server_port(Some(u16::MAX), None), 7_391);
}
2 changes: 1 addition & 1 deletion docs/ratatui-migration/04-protocol-and-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ source); use `#[serde(rename_all = "camelCase")]` on every struct.

- URL: `ws://{SERVER_HOST}:{SERVER_PORT}/` (no path used today)
- Default `SERVER_HOST = 127.0.0.1`, port resolved from `OPENSESSIONS_PORT`
env or hashed from `$TMUX` socket path → `17000 + (hash % 20000)`.
env or hashed from `$TMUX` socket path → `22000 + (hash % 20000)`.
- Single connection per pane. Auto-reconnect on close.
- Text frames only (JSON). No binary.

Expand Down
2 changes: 1 addition & 1 deletion docs/ratatui-migration/11-config-and-persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ fn server_port() -> u16 {
}
let key = server_key();
match key {
Some(k) => 17000 + k,
Some(k) => 22000 + k,
None => 7391, // DEFAULT_SERVER_PORT
}
}
Expand Down
31 changes: 27 additions & 4 deletions integrations/amp/opensessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,17 +123,40 @@ function hashServerKey(input: string): number {
return hash;
}

// Mirror Rust's strict integer parse (`parse::<u32>()` / `parse::<u16>()` in
// packages/runtime-rs/src/shared.rs). JavaScript's `Number.parseInt` is
// lenient ("123abc" -> 123, "0.5" -> 0) and `Number()` is too permissive
// ("1.0", "1e3", "0x10" all coerce to integers); both diverge from Rust,
// which would Err on those inputs and fall back to DEFAULT_SERVER_PORT.
// The optional leading `+` mirrors Rust's `u16::from_str` / `u32::from_str`
// (verified: `"+8123".parse::<u16>()` => Ok(8123) on rustc 1.96).
const DECIMAL_INTEGER_RE = /^\+?[0-9]+$/;

function resolveServerPort(): number {
const explicit = Number.parseInt(process.env.OPENSESSIONS_PORT ?? "", 10);
if (Number.isFinite(explicit) && explicit > 0) return explicit;
const explicitPort = process.env.OPENSESSIONS_PORT?.trim();
if (explicitPort && DECIMAL_INTEGER_RE.test(explicitPort)) {
const port = Number(explicitPort);
if (Number.isInteger(port) && port > 0 && port <= 65535) return port;
}

const explicitKey = process.env.OPENSESSIONS_SERVER_KEY?.trim();
if (explicitKey) return 17000 + Number.parseInt(explicitKey, 10);
if (explicitKey) {
if (DECIMAL_INTEGER_RE.test(explicitKey)) {
const key = Number(explicitKey);
if (Number.isInteger(key) && key >= 0) {
const port = 22000 + key;
if (port > 0 && port <= 65535) return port;
}
}
// Any explicit-but-invalid OPENSESSIONS_SERVER_KEY falls back to the
// default port (matching the Rust server) so client and server agree.
return DEFAULT_SERVER_PORT;
}

const tmux = process.env.TMUX?.trim();
if (tmux) {
const socketPath = tmux.split(",", 1)[0];
if (socketPath) return 17000 + hashServerKey(socketPath);
if (socketPath) return 22000 + hashServerKey(socketPath);
}
return DEFAULT_SERVER_PORT;
}
Expand Down
32 changes: 28 additions & 4 deletions integrations/pi-extension/opensessions-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,41 @@ function hashServerKey(input: string): number {
return hash;
}

// Mirror Rust's strict integer parse (`parse::<u32>()` / `parse::<u16>()` in
// packages/runtime-rs/src/shared.rs). JavaScript's `Number.parseInt` and
// `Number()` both accept formats Rust rejects ("123abc", "0.5", "1e3",
// "0x10"); the heartbeat client must Err on the same inputs or it will
// post to a different port than the server binds.
// The optional leading `+` mirrors Rust's `u16::from_str` / `u32::from_str`
// (verified: `"+8123".parse::<u16>()` => Ok(8123) on rustc 1.96).
const DECIMAL_INTEGER_RE = /^\+?[0-9]+$/;

function resolveServerPort(): number {
const explicit = Number.parseInt(process.env.OPENSESSIONS_PORT ?? "", 10);
if (Number.isFinite(explicit) && explicit > 0) return explicit;
const explicitPort = process.env.OPENSESSIONS_PORT?.trim();
if (explicitPort && DECIMAL_INTEGER_RE.test(explicitPort)) {
const port = Number(explicitPort);
if (Number.isInteger(port) && port > 0 && port <= 65535) return port;
}

const explicitKey = process.env.OPENSESSIONS_SERVER_KEY?.trim();
if (explicitKey) return 17000 + Number.parseInt(explicitKey, 10);
if (explicitKey) {
if (DECIMAL_INTEGER_RE.test(explicitKey)) {
const key = Number(explicitKey);
if (Number.isInteger(key) && key >= 0) {
const port = 22000 + key;
if (port > 0 && port <= 65535) return port;
}
}
// Any explicit-but-invalid OPENSESSIONS_SERVER_KEY falls back to the
// default port (matching the Rust server) so heartbeat client and
// server agree.
return DEFAULT_SERVER_PORT;
}

const tmux = process.env.TMUX?.trim();
if (tmux) {
const socketPath = tmux.split(",", 1)[0];
if (socketPath) return 17000 + hashServerKey(socketPath);
if (socketPath) return 22000 + hashServerKey(socketPath);
}
return DEFAULT_SERVER_PORT;
}
Expand Down
35 changes: 20 additions & 15 deletions packages/runtime-rs/src/shared.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
pub const DEFAULT_SERVER_PORT: u16 = 7_391;
pub const DEFAULT_SERVER_HOST: &str = "127.0.0.1";
// SERVER_PORT_BASE is canonically defined here. apps/tui-rs/src/runtime_config.rs
// keeps a duplicate copy because the TUI sidebar binary does not depend on this
// crate; both values must stay in sync.
pub const SERVER_PORT_BASE: u32 = 22_000;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServerSettings {
Expand Down Expand Up @@ -35,14 +39,14 @@ pub fn resolve_server_key(env: impl Fn(&str) -> Option<String>) -> Option<String
}

pub fn resolve_server_port(server_key: Option<&str>, explicit: Option<&str>) -> u16 {
resolve_server_port_with_base(server_key, explicit, 17_000)
resolve_server_port_with_base(server_key, explicit, SERVER_PORT_BASE)
}

/// Compute the port like [`resolve_server_port`] but with a configurable base.
/// Mirrors the `PORT_BASE` branch in
/// `integrations/tmux-plugin/scripts/server-common.sh` so the Rust server can
/// pin 22000+server_key when `OPENSESSIONS_RUST=1` and coexist with the TS
/// bun server (17000+server_key) on the same tmux socket.
/// `base + server_key` is computed in `u32`; if the sum overflows `u32` or
/// exceeds `u16::MAX`, falls back to `DEFAULT_SERVER_PORT` (matching the
/// parse-failure branch) and emits an `opensessions: ...` warning to stderr.
/// Mirrors `PORT_BASE` in `integrations/tmux-plugin/scripts/server-common.sh`.
pub fn resolve_server_port_with_base(
server_key: Option<&str>,
explicit: Option<&str>,
Expand All @@ -60,7 +64,16 @@ pub fn resolve_server_port_with_base(
};

match server_key.trim().parse::<u32>() {
Ok(key) => (base + key) as u16,
Ok(key) => base
.checked_add(key)
.and_then(|sum| u16::try_from(sum).ok())
.unwrap_or_else(|| {
eprintln!(
"opensessions: server_key {key} + base {base} overflows port range, \
falling back to DEFAULT_SERVER_PORT {DEFAULT_SERVER_PORT}",
);
DEFAULT_SERVER_PORT
}),
Err(_) => DEFAULT_SERVER_PORT,
}
}
Expand All @@ -87,18 +100,10 @@ pub fn resolve_pid_file(server_key: Option<&str>, explicit: Option<&str>) -> Str
pub fn resolve_server_settings(env: impl Fn(&str) -> Option<String>) -> ServerSettings {
let server_key = resolve_server_key(&env);
let host = resolve_server_host(env("OPENSESSIONS_HOST").as_deref());
let base = if env("OPENSESSIONS_RUST")
.map(|value| value.trim() == "1")
.unwrap_or(false)
{
22_000
} else {
17_000
};
let port = resolve_server_port_with_base(
server_key.as_deref(),
env("OPENSESSIONS_PORT").as_deref(),
base,
SERVER_PORT_BASE,
);
let pid_file = resolve_pid_file(
server_key.as_deref(),
Expand Down
56 changes: 31 additions & 25 deletions packages/runtime-rs/tests/config_and_shared.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,16 @@ use opensessions_runtime::shared::{
fn server_key_hash_and_port_resolution_match_typescript() {
assert_eq!(hash_server_key("/private/tmp/tmux-501/default"), 19_916);
assert_eq!(resolve_server_port(None, None), DEFAULT_SERVER_PORT);
assert_eq!(resolve_server_port(Some("19916"), None), 36_916);
assert_eq!(resolve_server_port(Some("19916"), None), 41_916);
assert_eq!(resolve_server_port(Some("19916"), Some("8123")), 8_123);
// Overflow fallback: keys that push base+key above u16::MAX (and
// even values that overflow the u32 checked_add) must fall back to
// DEFAULT_SERVER_PORT rather than silently wrap.
assert_eq!(resolve_server_port(Some("50000"), None), DEFAULT_SERVER_PORT);
assert_eq!(
resolve_server_port(Some("4294967295"), None),
DEFAULT_SERVER_PORT
);
}

#[test]
Expand All @@ -28,45 +36,43 @@ fn resolves_server_settings_from_tmux_socket_and_env_overrides() {

assert_eq!(settings.server_key.as_deref(), Some("19916"));
assert_eq!(settings.host, "0.0.0.0");
assert_eq!(settings.port, 36_916);
assert_eq!(settings.port, 41_916);
assert_eq!(settings.pid_file, "/tmp/opensessions.19916.pid");
}

#[test]
fn resolve_server_settings_uses_rust_port_base_when_opensessions_rust_set() {
// When OPENSESSIONS_RUST=1 the Rust server stack must bind a different
// port range (22000+server_key) so it can coexist with the TS bun server
// (17000+server_key) on the same tmux socket. Mirrors the PORT_BASE
// branch in integrations/tmux-plugin/scripts/server-common.sh.
let settings = resolve_server_settings(|key| match key {
"TMUX" => Some("/private/tmp/tmux-501/os-rs-test,123,0".to_string()),
fn resolve_server_settings_ignores_opensessions_rust_env_var() {
// OPENSESSIONS_RUST=1 was an opt-in toggle while the TS bun server
// (17000+server_key) coexisted with the Rust stack. The TS server is
// gone (commit 89168a3) and the Rust stack now always binds
// 22000+server_key (`SERVER_PORT_BASE`) regardless of this env var.
let tmux = "/private/tmp/tmux-501/os-rs-test,123,0";
let with_env_set = resolve_server_settings(|key| match key {
"TMUX" => Some(tmux.to_string()),
"OPENSESSIONS_RUST" => Some("1".to_string()),
_ => None,
});

assert_eq!(settings.server_key.as_deref(), Some("8011"));
assert_eq!(
settings.port, 30_011,
"OPENSESSIONS_RUST=1 must use base 22000 (got {})",
settings.port
);
}

#[test]
fn resolve_server_settings_keeps_ts_port_base_when_opensessions_rust_unset() {
let settings = resolve_server_settings(|key| match key {
"TMUX" => Some("/private/tmp/tmux-501/os-rs-test,123,0".to_string()),
let with_env_unset = resolve_server_settings(|key| match key {
"TMUX" => Some(tmux.to_string()),
_ => None,
});

assert_eq!(settings.port, 25_011);
assert_eq!(with_env_set.server_key.as_deref(), Some("8011"));
assert_eq!(
with_env_set.port, 30_011,
"Rust stack must bind base 22000 (got {})",
with_env_set.port
);
assert_eq!(
with_env_unset.port, with_env_set.port,
"OPENSESSIONS_RUST must have no effect on port resolution"
);
}

#[test]
fn resolve_server_settings_explicit_port_overrides_rust_base() {
fn resolve_server_settings_explicit_port_overrides_port_base() {
let settings = resolve_server_settings(|key| match key {
"TMUX" => Some("/private/tmp/tmux-501/os-rs-test,123,0".to_string()),
"OPENSESSIONS_RUST" => Some("1".to_string()),
"OPENSESSIONS_PORT" => Some("42424".to_string()),
_ => None,
});
Expand Down