Skip to content

Commit bc2708e

Browse files
authored
fix(desktop): keep sticky local server ports stable (#1386)
Co-authored-by: src-opn <src-opn@users.noreply.github.com>
1 parent 91ffa71 commit bc2708e

File tree

2 files changed

+138
-23
lines changed

2 files changed

+138
-23
lines changed

apps/desktop/src-tauri/src/openwork_server/mod.rs

Lines changed: 103 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use uuid::Uuid;
1515
use crate::types::OpenworkServerInfo;
1616
use crate::utils::now_ms;
1717
use crate::utils::truncate_output;
18+
use crate::workspace::state::normalize_local_workspace_path;
1819

1920
pub mod manager;
2021
pub mod spawn;
@@ -69,6 +70,14 @@ fn openwork_server_state_path(app: &AppHandle) -> Result<PathBuf, String> {
6970
Ok(data_dir.join("openwork-server-state.json"))
7071
}
7172

73+
fn normalize_workspace_key(workspace_key: &str) -> String {
74+
let trimmed = workspace_key.trim();
75+
if trimmed.is_empty() {
76+
return String::new();
77+
}
78+
normalize_local_workspace_path(trimmed)
79+
}
80+
7281
fn load_openwork_server_token_store(
7382
path: &Path,
7483
) -> Result<PersistedOpenworkServerTokenStore, String> {
@@ -140,12 +149,14 @@ fn save_openwork_server_state(
140149
Ok(())
141150
}
142151

143-
fn read_preferred_openwork_port(app: &AppHandle, workspace_key: &str) -> Result<Option<u16>, String> {
144-
let path = openwork_server_state_path(app)?;
145-
let state = load_openwork_server_state(&path)?;
146-
let trimmed = workspace_key.trim();
147-
if !trimmed.is_empty() {
148-
if let Some(port) = state.workspace_ports.get(trimmed) {
152+
fn read_preferred_openwork_port_at_path(
153+
path: &Path,
154+
workspace_key: &str,
155+
) -> Result<Option<u16>, String> {
156+
let state = load_openwork_server_state(path)?;
157+
let normalized = normalize_workspace_key(workspace_key);
158+
if !normalized.is_empty() {
159+
if let Some(port) = state.workspace_ports.get(&normalized) {
149160
return Ok(Some(*port));
150161
}
151162
}
@@ -155,13 +166,20 @@ fn read_preferred_openwork_port(app: &AppHandle, workspace_key: &str) -> Result<
155166
Ok(state.preferred_port)
156167
}
157168

158-
fn reserved_openwork_ports(app: &AppHandle, exclude_workspace_key: &str) -> Result<HashSet<u16>, String> {
169+
fn read_preferred_openwork_port(app: &AppHandle, workspace_key: &str) -> Result<Option<u16>, String> {
159170
let path = openwork_server_state_path(app)?;
160-
let state = load_openwork_server_state(&path)?;
161-
let excluded = exclude_workspace_key.trim();
171+
read_preferred_openwork_port_at_path(&path, workspace_key)
172+
}
173+
174+
fn reserved_openwork_ports_at_path(
175+
path: &Path,
176+
exclude_workspace_key: &str,
177+
) -> Result<HashSet<u16>, String> {
178+
let state = load_openwork_server_state(path)?;
179+
let excluded = normalize_workspace_key(exclude_workspace_key);
162180
let mut reserved = HashSet::new();
163181
for (workspace_key, port) in state.workspace_ports {
164-
if workspace_key.trim() == excluded {
182+
if workspace_key == excluded {
165183
continue;
166184
}
167185
reserved.insert(port);
@@ -174,22 +192,35 @@ fn reserved_openwork_ports(app: &AppHandle, exclude_workspace_key: &str) -> Resu
174192
Ok(reserved)
175193
}
176194

177-
fn persist_preferred_openwork_port(
178-
app: &AppHandle,
195+
fn reserved_openwork_ports(app: &AppHandle, exclude_workspace_key: &str) -> Result<HashSet<u16>, String> {
196+
let path = openwork_server_state_path(app)?;
197+
reserved_openwork_ports_at_path(&path, exclude_workspace_key)
198+
}
199+
200+
fn persist_preferred_openwork_port_at_path(
201+
path: &Path,
179202
workspace_key: &str,
180203
port: u16,
181204
) -> Result<(), String> {
182-
let path = openwork_server_state_path(app)?;
183-
let mut state = load_openwork_server_state(&path)?;
205+
let mut state = load_openwork_server_state(path)?;
184206
state.version = OPENWORK_SERVER_STATE_VERSION;
185-
let trimmed = workspace_key.trim();
186-
if trimmed.is_empty() {
207+
let normalized = normalize_workspace_key(workspace_key);
208+
if normalized.is_empty() {
187209
state.preferred_port = Some(port);
188210
} else {
189-
state.workspace_ports.insert(trimmed.to_string(), port);
211+
state.workspace_ports.insert(normalized, port);
190212
state.preferred_port = None;
191213
}
192-
save_openwork_server_state(&path, &state)
214+
save_openwork_server_state(path, &state)
215+
}
216+
217+
fn persist_preferred_openwork_port(
218+
app: &AppHandle,
219+
workspace_key: &str,
220+
port: u16,
221+
) -> Result<(), String> {
222+
let path = openwork_server_state_path(app)?;
223+
persist_preferred_openwork_port_at_path(&path, workspace_key, port)
193224
}
194225

195226
fn load_or_create_workspace_tokens(
@@ -205,7 +236,8 @@ fn load_or_create_workspace_tokens_at_path(
205236
workspace_key: &str,
206237
) -> Result<PersistedOpenworkServerTokens, String> {
207238
let mut store = load_openwork_server_token_store(path)?;
208-
if let Some(tokens) = store.workspaces.get(workspace_key) {
239+
let normalized = normalize_workspace_key(workspace_key);
240+
if let Some(tokens) = store.workspaces.get(&normalized) {
209241
return Ok(tokens.clone());
210242
}
211243

@@ -217,7 +249,7 @@ fn load_or_create_workspace_tokens_at_path(
217249
};
218250
store
219251
.workspaces
220-
.insert(workspace_key.to_string(), tokens.clone());
252+
.insert(normalized, tokens.clone());
221253
save_openwork_server_token_store(path, &store)?;
222254
Ok(tokens)
223255
}
@@ -237,7 +269,8 @@ fn persist_workspace_owner_token_at_path(
237269
owner_token: &str,
238270
) -> Result<(), String> {
239271
let mut store = load_openwork_server_token_store(path)?;
240-
let Some(tokens) = store.workspaces.get_mut(workspace_key) else {
272+
let normalized = normalize_workspace_key(workspace_key);
273+
let Some(tokens) = store.workspaces.get_mut(&normalized) else {
241274
return Ok(());
242275
};
243276
tokens.owner_token = Some(owner_token.to_string());
@@ -474,4 +507,53 @@ mod tests {
474507

475508
let _ = fs::remove_file(path);
476509
}
510+
511+
#[test]
512+
fn reuses_workspace_tokens_across_canonical_path_aliases() {
513+
let path = unique_temp_path("token-path-alias");
514+
let workspace = PathBuf::from(format!(
515+
"/tmp/openwork-workspace-token-alias-{}-{}",
516+
std::process::id(),
517+
now_ms()
518+
));
519+
fs::create_dir_all(&workspace).expect("create temp workspace");
520+
let canonical = fs::canonicalize(&workspace).expect("canonical workspace path");
521+
522+
let first = load_or_create_workspace_tokens_at_path(&path, &workspace.to_string_lossy())
523+
.expect("store tokens using raw path");
524+
let second = load_or_create_workspace_tokens_at_path(&path, &canonical.to_string_lossy())
525+
.expect("load tokens using canonical path");
526+
527+
assert_eq!(first.client_token, second.client_token);
528+
assert_eq!(first.host_token, second.host_token);
529+
530+
let _ = fs::remove_file(path);
531+
let _ = fs::remove_dir_all(workspace);
532+
}
533+
534+
#[test]
535+
fn reuses_workspace_port_across_canonical_path_aliases() {
536+
let path = unique_temp_path("port-path-alias");
537+
let workspace = PathBuf::from(format!(
538+
"/tmp/openwork-workspace-port-alias-{}-{}",
539+
std::process::id(),
540+
now_ms()
541+
));
542+
fs::create_dir_all(&workspace).expect("create temp workspace");
543+
let canonical = fs::canonicalize(&workspace).expect("canonical workspace path");
544+
545+
persist_preferred_openwork_port_at_path(&path, &workspace.to_string_lossy(), 49_123)
546+
.expect("persist preferred port using raw path");
547+
548+
let preferred = read_preferred_openwork_port_at_path(&path, &canonical.to_string_lossy())
549+
.expect("read preferred port using canonical path");
550+
let reserved = reserved_openwork_ports_at_path(&path, &canonical.to_string_lossy())
551+
.expect("read reserved ports using canonical path");
552+
553+
assert_eq!(preferred, Some(49_123));
554+
assert!(!reserved.contains(&49_123));
555+
556+
let _ = fs::remove_file(path);
557+
let _ = fs::remove_dir_all(workspace);
558+
}
477559
}

apps/desktop/src-tauri/src/openwork_server/spawn.rs

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
use std::collections::HashSet;
22
use std::net::TcpListener;
33
use std::path::Path;
4-
use std::time::{SystemTime, UNIX_EPOCH};
4+
use std::thread;
5+
use std::time::{Duration, SystemTime, UNIX_EPOCH};
56

67
use tauri::async_runtime::Receiver;
78
use tauri::AppHandle;
@@ -10,6 +11,8 @@ use tauri_plugin_shell::ShellExt;
1011

1112
pub const OPENWORK_PORT_RANGE_START: u16 = 48_000;
1213
pub const OPENWORK_PORT_RANGE_END: u16 = 51_000;
14+
const PREFERRED_PORT_RETRY_ATTEMPTS: usize = 20;
15+
const PREFERRED_PORT_RETRY_DELAY_MS: u64 = 50;
1316

1417
fn bind_available_port(host: &str, port: u16) -> bool {
1518
TcpListener::bind((host, port)).is_ok()
@@ -27,13 +30,25 @@ fn random_range_offset() -> usize {
2730
usize::try_from(nanos).unwrap_or(0) % range_port_count()
2831
}
2932

33+
fn wait_for_preferred_port(host: &str, port: u16) -> bool {
34+
for attempt in 0..=PREFERRED_PORT_RETRY_ATTEMPTS {
35+
if bind_available_port(host, port) {
36+
return true;
37+
}
38+
if attempt < PREFERRED_PORT_RETRY_ATTEMPTS {
39+
thread::sleep(Duration::from_millis(PREFERRED_PORT_RETRY_DELAY_MS));
40+
}
41+
}
42+
false
43+
}
44+
3045
pub fn resolve_openwork_port(
3146
host: &str,
3247
preferred_port: Option<u16>,
3348
reserved_ports: &HashSet<u16>,
3449
) -> Result<u16, String> {
3550
if let Some(port) = preferred_port.filter(|port| *port > 0) {
36-
if !reserved_ports.contains(&port) && bind_available_port(host, port) {
51+
if !reserved_ports.contains(&port) && wait_for_preferred_port(host, port) {
3752
return Ok(port);
3853
}
3954
}
@@ -70,6 +85,7 @@ mod tests {
7085
use super::{resolve_openwork_port, OPENWORK_PORT_RANGE_END, OPENWORK_PORT_RANGE_START};
7186
use std::collections::HashSet;
7287
use std::net::TcpListener;
88+
use std::time::Duration;
7389

7490
#[test]
7591
fn uses_preferred_port_when_available() {
@@ -110,6 +126,23 @@ mod tests {
110126
assert!(resolved >= OPENWORK_PORT_RANGE_START);
111127
assert!(resolved <= OPENWORK_PORT_RANGE_END);
112128
}
129+
130+
#[test]
131+
fn waits_briefly_to_reuse_preferred_port_during_restart() {
132+
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind preferred port");
133+
let preferred_port = listener.local_addr().expect("listener addr").port();
134+
135+
let release = std::thread::spawn(move || {
136+
std::thread::sleep(Duration::from_millis(150));
137+
drop(listener);
138+
});
139+
140+
let resolved = resolve_openwork_port("127.0.0.1", Some(preferred_port), &HashSet::new())
141+
.expect("resolve restarted preferred port");
142+
143+
release.join().expect("join release thread");
144+
assert_eq!(resolved, preferred_port);
145+
}
113146
}
114147

115148
pub fn build_openwork_args(

0 commit comments

Comments
 (0)