Skip to content

Commit 34fe6a3

Browse files
authored
Merge pull request #222 from emanzx/fix/209-sync-listener-config
fix(sync): make sync listener address configurable and bind failure boot-fatal
2 parents f7fcc77 + c796452 commit 34fe6a3

17 files changed

Lines changed: 285 additions & 94 deletions

File tree

docs/getting-started.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ log_format = "text" # "text" or "json"
223223
native = 6433 # Always-on protocols have defaults
224224
pgwire = 6432
225225
http = 6480
226+
sync = 9090 # Always on
226227
resp = 6381 # Optional: set to enable, omit to disable
227228
ilp = 8086 # Optional: set to enable, omit to disable
228229
@@ -236,6 +237,10 @@ resp = true
236237
ilp = false # Example: disable TLS for ILP ingest
237238
```
238239

240+
Every listener binds during startup, before the server accepts any
241+
connection. If a configured port is already in use, startup fails with that
242+
port named — the server never comes up missing a protocol.
243+
239244
**Server settings:**
240245

241246
| Config field | Environment variable | Default |
@@ -244,6 +249,7 @@ ilp = false # Example: disable TLS for ILP ingest
244249
| `ports.native` | `NODEDB_PORT_NATIVE` | `6433` |
245250
| `ports.pgwire` | `NODEDB_PORT_PGWIRE` | `6432` |
246251
| `ports.http` | `NODEDB_PORT_HTTP` | `6480` |
252+
| `ports.sync` | `NODEDB_PORT_SYNC` | `9090` |
247253
| `ports.resp` | `NODEDB_PORT_RESP` | disabled |
248254
| `ports.ilp` | `NODEDB_PORT_ILP` | disabled |
249255
| `data_dir` | `NODEDB_DATA_DIR` | `~/.nodedb/data` (binary), `/var/lib/nodedb` (Docker) |

docs/protocols.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ SELECT * FROM cpu WHERE ts > now() - INTERVAL '1 hour';
223223

224224
CRDT sync protocol for NodeDB-Lite clients (mobile, WASM, desktop). Bidirectional delta exchange over WebSocket.
225225

226-
**Port:** 9090 (default)
226+
**Port:** 9090 (default). Configurable via `[server.ports] sync` or `NODEDB_PORT_SYNC`.
227227

228228
**Flow:**
229229

@@ -256,6 +256,7 @@ host = "127.0.0.1"
256256
pgwire = 6432 # Always on
257257
native = 6433 # Always on
258258
http = 6480 # Always on
259+
sync = 9090 # Always on
259260
resp = 6381 # Set to enable (omit to disable)
260261
ilp = 8086 # Set to enable (omit to disable)
261262

@@ -269,7 +270,7 @@ resp = true
269270
ilp = false # Example: disable TLS for ILP ingest
270271
```
271272

272-
Environment variables override config: `NODEDB_PORT_PGWIRE`, `NODEDB_PORT_NATIVE`, `NODEDB_PORT_HTTP`, `NODEDB_PORT_RESP`, `NODEDB_PORT_ILP`.
273+
Environment variables override config: `NODEDB_PORT_PGWIRE`, `NODEDB_PORT_NATIVE`, `NODEDB_PORT_HTTP`, `NODEDB_PORT_SYNC`, `NODEDB_PORT_RESP`, `NODEDB_PORT_ILP`.
273274

274275
## Native Protocol Opcodes (SDK Reference)
275276

docs/wasm.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ const db = new NodeDbLite();
173173

174174
// Configure sync to Origin
175175
await db.sync_config({
176-
server_url: "ws://localhost:6433",
176+
server_url: "ws://localhost:9090",
177177
auth_token: "your-token",
178178
auto_sync: true,
179179
sync_interval_ms: 5000,

nodedb/src/bootstrap/listeners.rs

Lines changed: 61 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
55
use std::sync::Arc;
66

7+
use anyhow::Context;
8+
use tokio::net::TcpListener;
79
use tracing::info;
810

911
use crate::ServerConfig;
@@ -16,9 +18,14 @@ use crate::control::shutdown::ShutdownBus;
1618
use crate::control::startup::StartupGate;
1719
use crate::control::state::SharedState;
1820

19-
/// The three protocol listeners passed to [`spawn_protocol_listeners`].
21+
/// The pre-bound protocol listeners passed to [`spawn_protocol_listeners`].
22+
///
23+
/// Every socket here is already bound by [`bind_listeners`], so spawning
24+
/// cannot fail on a port conflict.
2025
pub struct ProtocolListeners {
2126
pub pg_listener: PgListener,
27+
pub http_listener: TcpListener,
28+
pub sync_listener: TcpListener,
2229
pub ilp_listener: Option<IlpListener>,
2330
pub resp_listener: Option<RespListener>,
2431
}
@@ -34,6 +41,10 @@ pub struct ListenerInfra {
3441
///
3542
/// The native listener is not spawned here — it is run on the main task
3643
/// by the caller after this returns.
44+
///
45+
/// Infallible by construction: every socket was bound by [`bind_listeners`]
46+
/// before this point, so a port conflict has already aborted boot while
47+
/// nothing was exposed. Nothing here may silently swallow a bind failure.
3748
pub async fn spawn_protocol_listeners(
3849
listeners: ProtocolListeners,
3950
shared: Arc<SharedState>,
@@ -44,6 +55,8 @@ pub async fn spawn_protocol_listeners(
4455
) {
4556
let ProtocolListeners {
4657
pg_listener,
58+
http_listener,
59+
sync_listener,
4760
ilp_listener,
4861
resp_listener,
4962
} = listeners;
@@ -84,10 +97,9 @@ pub async fn spawn_protocol_listeners(
8497
}
8598
});
8699

87-
// HTTP API server.
100+
// HTTP API server (on the socket bound by `bind_listeners`).
88101
let shared_http = Arc::clone(&shared);
89102
let http_auth_mode = config.auth.mode.clone();
90-
let http_listen = config.http_addr();
91103
let http_tls = if http_tls_enabled {
92104
config.server.tls.clone()
93105
} else {
@@ -96,7 +108,7 @@ pub async fn spawn_protocol_listeners(
96108
let bus_http = shutdown_bus.clone();
97109
tokio::spawn(async move {
98110
if let Err(e) = crate::control::server::http::server::run(
99-
http_listen,
111+
http_listener,
100112
shared_http,
101113
http_auth_mode,
102114
http_tls.as_ref(),
@@ -156,25 +168,22 @@ pub async fn spawn_protocol_listeners(
156168
});
157169
}
158170

159-
// Sync WebSocket listener for NodeDB-Lite clients.
160-
let sync_config = crate::control::server::sync::listener::SyncListenerConfig::default();
161-
match crate::control::server::sync::listener::start_sync_listener(
171+
// Sync WebSocket listener for NodeDB-Lite clients (socket already bound).
172+
let sync_config = crate::control::server::sync::listener::SyncListenerConfig {
173+
listen_addr: config.sync_addr(),
174+
..Default::default()
175+
};
176+
let sync_state = crate::control::server::sync::listener::serve_sync_listener(
177+
sync_listener,
162178
sync_config,
163179
Some(Arc::clone(&shared)),
164180
)
165-
.await
166-
{
167-
Ok(sync_state) => {
168-
info!(
169-
addr = %sync_state.config.listen_addr,
170-
max_sessions = sync_state.config.max_sessions,
171-
"sync WebSocket listener started"
172-
);
173-
}
174-
Err(e) => {
175-
tracing::warn!(error = %e, "sync listener failed to start (non-fatal)");
176-
}
177-
}
181+
.await;
182+
info!(
183+
addr = %sync_state.config.listen_addr,
184+
max_sessions = sync_state.config.max_sessions,
185+
"sync WebSocket listener started"
186+
);
178187

179188
// Signal readiness to systemd and cluster lifecycle.
180189
if let Some(handle) = cluster_handle {
@@ -184,18 +193,34 @@ pub async fn spawn_protocol_listeners(
184193
nodedb_cluster::readiness::notify_ready();
185194
}
186195

196+
/// Every protocol socket, bound before any accept loop starts.
197+
pub struct BoundListeners {
198+
pub native: Listener,
199+
pub pgwire: PgListener,
200+
pub http: TcpListener,
201+
pub sync: TcpListener,
202+
pub ilp: Option<IlpListener>,
203+
pub resp: Option<RespListener>,
204+
}
205+
187206
/// Bind all protocol listeners to their configured addresses.
188-
pub async fn bind_listeners(
189-
config: &ServerConfig,
190-
) -> anyhow::Result<(
191-
Listener,
192-
PgListener,
193-
Option<IlpListener>,
194-
Option<RespListener>,
195-
)> {
207+
///
208+
/// This is the single fail-fast point for listener setup: it runs before the
209+
/// node waits on cluster readiness and before any accept loop is spawned, so
210+
/// a port conflict on *any* protocol — including HTTP and sync, which serve
211+
/// from detached tasks — aborts boot while nothing is exposed yet. Never
212+
/// move a bind out of here into a spawned task; that is how a server ends up
213+
/// running for days missing a listener behind one warning line.
214+
pub async fn bind_listeners(config: &ServerConfig) -> anyhow::Result<BoundListeners> {
196215
let native = crate::control::server::listener::Listener::bind(config.native_addr()).await?;
197216
let pgwire =
198217
crate::control::server::pgwire::listener::PgListener::bind(config.pgwire_addr()).await?;
218+
let http = TcpListener::bind(config.http_addr())
219+
.await
220+
.with_context(|| format!("bind HTTP API listener to {}", config.http_addr()))?;
221+
let sync = crate::control::server::sync::listener::bind_sync_listener(config.sync_addr())
222+
.await
223+
.context("sync listener failed to bind")?;
199224
let ilp = if let Some(ilp_addr) = config.ilp_addr() {
200225
Some(crate::control::server::ilp_listener::IlpListener::bind(ilp_addr).await?)
201226
} else {
@@ -206,5 +231,12 @@ pub async fn bind_listeners(
206231
} else {
207232
None
208233
};
209-
Ok((native, pgwire, ilp, resp))
234+
Ok(BoundListeners {
235+
native,
236+
pgwire,
237+
http,
238+
sync,
239+
ilp,
240+
resp,
241+
})
210242
}

nodedb/src/config/server/config.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ use crate::config::EngineConfig;
3939
/// pgwire = 6432
4040
/// native = 6433
4141
/// http = 6480
42+
/// sync = 9090
4243
///
4344
/// [auth]
4445
/// # ...
@@ -161,6 +162,11 @@ impl ServerConfig {
161162
self.addr(self.server.ports.http)
162163
}
163164

165+
/// Sync WebSocket listen address (NodeDB-Lite clients).
166+
pub fn sync_addr(&self) -> SocketAddr {
167+
self.addr(self.server.ports.sync)
168+
}
169+
164170
/// RESP listen address (None if disabled).
165171
pub fn resp_addr(&self) -> Option<SocketAddr> {
166172
self.server.ports.resp.map(|p| self.addr(p))
@@ -200,6 +206,7 @@ mod tests {
200206
assert_eq!(cfg.server.ports.native, 6433);
201207
assert_eq!(cfg.server.ports.pgwire, 6432);
202208
assert_eq!(cfg.server.ports.http, 6480);
209+
assert_eq!(cfg.server.ports.sync, 9090);
203210
assert!(cfg.server.ports.resp.is_none());
204211
assert!(cfg.server.ports.ilp.is_none());
205212
assert!(cfg.server.data_plane_cores >= 1);

nodedb/src/config/server/env.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ fn apply_bool_env(var: &str, target: &mut bool) {
180180
/// - `NODEDB_PORT_NATIVE` — overrides `config.ports.native` (default 6433)
181181
/// - `NODEDB_PORT_PGWIRE` — overrides `config.ports.pgwire` (default 6432)
182182
/// - `NODEDB_PORT_HTTP` — overrides `config.ports.http` (default 6480)
183+
/// - `NODEDB_PORT_SYNC` — overrides `config.ports.sync` (default 9090)
183184
/// - `NODEDB_PORT_RESP` — overrides `config.ports.resp` (set to enable RESP)
184185
/// - `NODEDB_PORT_ILP` — overrides `config.ports.ilp` (set to enable ILP)
185186
/// - `NODEDB_DATA_DIR` — overrides `config.data_dir`
@@ -235,6 +236,7 @@ pub fn apply_env_overrides(config: &mut ServerConfig) {
235236
apply_port_env("NODEDB_PORT_NATIVE", &mut config.server.ports.native);
236237
apply_port_env("NODEDB_PORT_PGWIRE", &mut config.server.ports.pgwire);
237238
apply_port_env("NODEDB_PORT_HTTP", &mut config.server.ports.http);
239+
apply_port_env("NODEDB_PORT_SYNC", &mut config.server.ports.sync);
238240
apply_optional_port_env("NODEDB_PORT_RESP", &mut config.server.ports.resp);
239241
apply_optional_port_env("NODEDB_PORT_ILP", &mut config.server.ports.ilp);
240242

@@ -679,6 +681,29 @@ mod tests {
679681
unsafe { std::env::remove_var("NODEDB_MEMORY_LIMIT") };
680682
}
681683

684+
/// Tests valid and malformed `NODEDB_PORT_SYNC` sequentially to avoid
685+
/// env-var races (env vars are process-global, Rust tests run in parallel).
686+
#[test]
687+
fn env_sync_port_overrides() {
688+
// ── Valid value → overrides ports.sync ──
689+
unsafe { std::env::set_var("NODEDB_PORT_SYNC", "19090") };
690+
let mut cfg = ServerConfig::default();
691+
apply_env_overrides(&mut cfg);
692+
assert_eq!(cfg.server.ports.sync, 19090);
693+
694+
// ── Malformed value → ports.sync unchanged ──
695+
unsafe { std::env::set_var("NODEDB_PORT_SYNC", "notaport") };
696+
let mut cfg = ServerConfig::default();
697+
let before = cfg.server.ports.sync;
698+
apply_env_overrides(&mut cfg);
699+
assert_eq!(
700+
cfg.server.ports.sync, before,
701+
"malformed value must not change config"
702+
);
703+
704+
unsafe { std::env::remove_var("NODEDB_PORT_SYNC") };
705+
}
706+
682707
#[test]
683708
fn env_cluster_overrides() {
684709
// Always start clean.

nodedb/src/config/server/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ pub use observability::{
2525
ObservabilityConfig, OtlpConfig, OtlpExportConfig, OtlpReceiverConfig, PromqlConfig,
2626
apply_observability_env, validate_feature_availability,
2727
};
28-
pub use ports::PortsConfig;
28+
pub use ports::{DEFAULT_SYNC_PORT, PortsConfig};
2929
pub use retention::RetentionSettings;
3030
pub use scheduler::{CronTimezone, SchedulerConfig};
3131
pub use section::ServerSection;

nodedb/src/config/server/ports.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@
44
55
use serde::{Deserialize, Serialize};
66

7+
/// Default sync WebSocket port.
8+
///
9+
/// Exported because the sync listener's own `SyncListenerConfig::default()`
10+
/// must agree with the config default — one constant, not two literals that
11+
/// can drift apart.
12+
pub const DEFAULT_SYNC_PORT: u16 = 9090;
13+
714
/// Port configuration for all protocol listeners.
815
///
916
/// Always-on protocols have a default port. Optional protocols (RESP, ILP)
@@ -20,6 +27,9 @@ pub struct PortsConfig {
2027
/// HTTP API port (REST, SSE, WebSocket). Default: 6480.
2128
#[serde(default = "default_http_port")]
2229
pub http: u16,
30+
/// Sync WebSocket port (NodeDB-Lite clients). Default: 9090.
31+
#[serde(default = "default_sync_port")]
32+
pub sync: u16,
2333
/// RESP (Redis-compatible) port. Disabled by default. Set to enable.
2434
#[serde(default)]
2535
pub resp: Option<u16>,
@@ -34,6 +44,7 @@ impl Default for PortsConfig {
3444
native: default_native_port(),
3545
pgwire: default_pgwire_port(),
3646
http: default_http_port(),
47+
sync: default_sync_port(),
3748
resp: None,
3849
ilp: None,
3950
}
@@ -49,6 +60,9 @@ fn default_pgwire_port() -> u16 {
4960
fn default_http_port() -> u16 {
5061
6480
5162
}
63+
fn default_sync_port() -> u16 {
64+
DEFAULT_SYNC_PORT
65+
}
5266

5367
#[cfg(test)]
5468
mod tests {
@@ -60,6 +74,7 @@ mod tests {
6074
assert_eq!(p.native, 6433);
6175
assert_eq!(p.pgwire, 6432);
6276
assert_eq!(p.http, 6480);
77+
assert_eq!(p.sync, 9090);
6378
assert!(p.resp.is_none());
6479
assert!(p.ilp.is_none());
6580
}

0 commit comments

Comments
 (0)