Skip to content

Commit 571934d

Browse files
authored
fix(binkp): apply config.hjson reloads without a restart (#721)
config.hjson is watched and reloaded at runtime, but the BinkP module read most of its configuration once during startup and threaded the values into long-lived closures. The inbound listener in particular captured a BsoSpool, the local address list, and the whole binkp config block, then reused them for every connection for the life of the process. After a reload, inbound sessions kept resolving outbound directories against the old paths and networks, advertised the old local addresses, and authenticated against the old node passwords -- silently, until the BBS was restarted. The pull cycle dialed the node set as it was at boot, and the crashmail debounce window was fixed at its startup value. Nothing here needed a new mechanism: binkp.js binds Config as a call-time indirection already, so the fix is to stop reading early. * _handleConnection() resolves the spool, local addresses, node/FREQ config, and tempDir per connection -- once at the top, so values can't shift mid-session. Both listeners now close over nothing but `this`. * _pullAddresses() defaults to the live config; the parameter stays for callers evaluating some other node set. * the crashmail debounce window is read per burst. Two things can't be fixed by reading later, and are reconciled on the ConfigChanged event instead: * pullSchedule is compiled into a later.js timer, so the timer is rebuilt when the expression changes (and cleared when it is removed). * the inbound listener bindings -- enabled, port, address, tls.* -- are baked into a socket at listen() time. Rebinding under live sessions is not something a reload should do, so a change to those keys is reported as restart-required rather than silently ignored. Also consolidates the three near-identical BsoSpool constructions into a single buildSpool() in binkp/util.js, whose header already asked for exactly that ("...so the three callsites can't drift").
1 parent d7260b5 commit 571934d

6 files changed

Lines changed: 498 additions & 51 deletions

File tree

core/binkp/caller.js

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,28 +11,20 @@ const Events = require('../events.js');
1111
const configModule = require('../config.js');
1212
const Address = require('../ftn_address.js');
1313
const { BinkpSession } = require('./session.js');
14-
const { BsoSpool, attachSpoolToSession } = require('./bso_spool.js');
15-
const { localAddresses, addressKey, findBestNodeMatch } = require('./util.js');
14+
const { attachSpoolToSession } = require('./bso_spool.js');
15+
const {
16+
localAddresses,
17+
addressKey,
18+
findBestNodeMatch,
19+
buildSpool,
20+
} = require('./util.js');
1621

1722
const Config = () => configModule.get();
1823

1924
const CONNECT_TIMEOUT_MS = 30_000;
2025

2126
// ── Helpers ───────────────────────────────────────────────────────────────────
2227

23-
function buildSpool() {
24-
const config = Config();
25-
return new BsoSpool({
26-
paths: _.get(config, 'scannerTossers.ftn_bso.paths'),
27-
networks: _.get(config, 'messageNetworks.ftn.networks', {}),
28-
defaultNetwork: _.get(config, 'scannerTossers.ftn_bso.defaultNetwork'),
29-
staleLockMaxAgeMs: _.get(
30-
config,
31-
'scannerTossers.ftn_bso.binkp.staleLockMaxAgeMs'
32-
),
33-
});
34-
}
35-
3628
// Returns the most-specific node config entry whose pattern matches |addr|.
3729
// Specificity is by Address#getMatchScore — concrete-and-matching parts beat
3830
// wildcards — so a "21:1/100" override always wins over a "21:*" catch-all
@@ -142,7 +134,7 @@ async function pollNodes(forceAddrs, cb) {
142134
return cb(null);
143135
}
144136

145-
const spool = buildSpool();
137+
const spool = buildSpool(config);
146138

147139
let pendingAddrs;
148140
try {

core/binkp/util.js

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
const _ = require('lodash');
44
const Address = require('../ftn_address.js');
5+
const { BsoSpool } = require('./bso_spool.js');
56

67
// Helpers shared across the BinkP module surface (caller, scanner_tosser).
78
// Anything reaching for `messageNetworks.ftn.networks` or the in-memory
@@ -66,4 +67,24 @@ function findBestNodeMatch(nodes, addr) {
6667
return bestConf;
6768
}
6869

69-
module.exports = { localAddresses, addressKey, findBestNodeMatch };
70+
//
71+
// A BsoSpool for the current configuration.
72+
//
73+
// Build one at the point of use and throw it away -- never cache it in a
74+
// closure. config.hjson is hot-reloadable, so a poll or an inbound session
75+
// starting after a reload has to honour the new paths and networks. Holding
76+
// a spool built at startup silently pins the process to boot-time config.
77+
//
78+
// |config| is the full Config() object, as with localAddresses() above.
79+
//
80+
function buildSpool(config) {
81+
const ftnBsoCfg = _.get(config, 'scannerTossers.ftn_bso', {});
82+
return new BsoSpool({
83+
paths: ftnBsoCfg.paths,
84+
networks: _.get(config, 'messageNetworks.ftn.networks', {}),
85+
defaultNetwork: ftnBsoCfg.defaultNetwork,
86+
staleLockMaxAgeMs: _.get(ftnBsoCfg, 'binkp.staleLockMaxAgeMs'),
87+
});
88+
}
89+
90+
module.exports = { localAddresses, addressKey, findBestNodeMatch, buildSpool };

core/scanner_tossers/binkp.js

Lines changed: 128 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,14 @@ const configModule = require('../config.js');
1515
const Address = require('../ftn_address.js');
1616
const { MessageScanTossModule } = require('../msg_scan_toss_module.js');
1717
const { BinkpSession } = require('../binkp/session.js');
18-
const { BsoSpool, attachSpoolToSession } = require('../binkp/bso_spool.js');
18+
const { attachSpoolToSession } = require('../binkp/bso_spool.js');
1919
const { pollNodes } = require('../binkp/caller.js');
20-
const { localAddresses, addressKey, findBestNodeMatch } = require('../binkp/util.js');
20+
const {
21+
localAddresses,
22+
addressKey,
23+
findBestNodeMatch,
24+
buildSpool,
25+
} = require('../binkp/util.js');
2126
const { FreqResolver, attachFreqToSession } = require('../binkp/freq.js');
2227

2328
const Config = () => configModule.get();
@@ -28,6 +33,20 @@ const Config = () => configModule.get();
2833
// scannerTossers.ftn_bso.binkp.crashmailDebounceMs.
2934
const DEFAULT_CRASHMAIL_DEBOUNCE_MS = 500;
3035

36+
// Settings baked into a socket at listen() time. Unlike everything else in
37+
// the binkp block, these cannot be picked up by re-reading config: applying
38+
// them means tearing down and rebinding a listener, potentially under live
39+
// sessions. A reload that touches them is reported instead.
40+
const LISTENER_BOUND_KEYS = [
41+
'inbound.enabled',
42+
'inbound.port',
43+
'inbound.address',
44+
'inbound.tls.enabled',
45+
'inbound.tls.port',
46+
'inbound.tls.certFile',
47+
'inbound.tls.keyFile',
48+
];
49+
3150
// Inbound temp file (binkp_in_*.dt) startup-sweep age threshold. Anything
3251
// older than this in tempDir at startup is treated as a leaked partial from
3352
// a prior crashed session and removed. Tunable via
@@ -85,6 +104,9 @@ exports.getModule = class BinkpModule extends MessageScanTossModule {
85104
this._crashmailTimer = null;
86105
this._crashmailPending = new Map(); // zone:net/node -> Address
87106
this._crashmailListener = null;
107+
this._configChangedListener = null;
108+
this._listenerConfig = null; // boot-time LISTENER_BOUND_KEYS snapshot
109+
this._pullSchedule = null;
88110
}
89111

90112
startup(cb) {
@@ -97,11 +119,12 @@ exports.getModule = class BinkpModule extends MessageScanTossModule {
97119

98120
async.series(
99121
[
100-
callback => this._reapStaleLocks(binkpCfg, ftnBsoCfg, callback),
122+
callback => this._reapStaleLocks(callback),
101123
callback => this._reapInboundTemps(binkpCfg, callback),
102-
callback => this._startInbound(binkpCfg, ftnBsoCfg, callback),
124+
callback => this._startInbound(binkpCfg, callback),
103125
callback => this._startPullSchedule(binkpCfg, callback),
104-
callback => this._startCrashmailListener(binkpCfg, callback),
126+
callback => this._startCrashmailListener(callback),
127+
callback => this._startConfigWatch(binkpCfg, callback),
105128
],
106129
cb
107130
);
@@ -117,6 +140,13 @@ exports.getModule = class BinkpModule extends MessageScanTossModule {
117140
this._crashmailTimer = null;
118141
}
119142
this._crashmailPending.clear();
143+
if (this._configChangedListener) {
144+
Events.removeListener(
145+
Events.getSystemEvents().ConfigChanged,
146+
this._configChangedListener
147+
);
148+
this._configChangedListener = null;
149+
}
120150
if (this._crashmailListener) {
121151
Events.removeListener(
122152
Events.getSystemEvents().NewOutboundBSO,
@@ -154,14 +184,8 @@ exports.getModule = class BinkpModule extends MessageScanTossModule {
154184
// Sweep orphaned .bsy locks left by a prior crashed run before we start
155185
// anything that depends on them. Runs unconditionally — outbound sessions
156186
// acquire locks too, so this matters even when inbound is disabled.
157-
_reapStaleLocks(binkpCfg, ftnBsoCfg, cb) {
158-
const spool = new BsoSpool({
159-
paths: ftnBsoCfg.paths,
160-
networks: _.get(Config(), 'messageNetworks.ftn.networks', {}),
161-
defaultNetwork: ftnBsoCfg.defaultNetwork,
162-
staleLockMaxAgeMs: binkpCfg.staleLockMaxAgeMs,
163-
});
164-
spool
187+
_reapStaleLocks(cb) {
188+
buildSpool(Config())
165189
.reapStaleLocks()
166190
.then(reaped => {
167191
if (reaped > 0) {
@@ -208,23 +232,17 @@ exports.getModule = class BinkpModule extends MessageScanTossModule {
208232
});
209233
}
210234

211-
_startInbound(binkpCfg, ftnBsoCfg, cb) {
235+
_startInbound(binkpCfg, cb) {
212236
const inbound = _.get(binkpCfg, 'inbound', {});
213237
if (!inbound.enabled) return cb(null);
214238

215-
const spool = new BsoSpool({
216-
paths: ftnBsoCfg.paths,
217-
networks: _.get(Config(), 'messageNetworks.ftn.networks', {}),
218-
defaultNetwork: ftnBsoCfg.defaultNetwork,
219-
staleLockMaxAgeMs: binkpCfg.staleLockMaxAgeMs,
220-
});
221-
222-
const addresses = localAddresses(Config());
223-
const tempDir = _.get(binkpCfg, 'tempDir', os.tmpdir());
224239
const bindAddress = inbound.address || '0.0.0.0';
225240

241+
// Only the binding itself is fixed for the life of the socket.
242+
// Everything a session reads is resolved per connection in
243+
// _handleConnection() so a config reload applies without a restart.
226244
this._server = net.createServer(socket => {
227-
this._handleConnection(socket, spool, addresses, binkpCfg, tempDir);
245+
this._handleConnection(socket);
228246
});
229247
this._server.on('error', err => {
230248
Log.error({ error: err.message }, '[BinkP] Server error');
@@ -257,7 +275,7 @@ exports.getModule = class BinkpModule extends MessageScanTossModule {
257275
Promise.all([fsp.readFile(tlsCfg.certFile), fsp.readFile(tlsCfg.keyFile)])
258276
.then(([cert, key]) => {
259277
this._tlsServer = tls.createServer({ cert, key }, socket => {
260-
this._handleConnection(socket, spool, addresses, binkpCfg, tempDir);
278+
this._handleConnection(socket);
261279
});
262280
this._tlsServer.on('error', err => {
263281
Log.error({ error: err.message }, '[BinkP] TLS server error');
@@ -312,7 +330,7 @@ exports.getModule = class BinkpModule extends MessageScanTossModule {
312330
this._pullTimer = later.setInterval(() => {
313331
if (polling) return;
314332
polling = true;
315-
const addrs = this._pullAddresses(binkpCfg);
333+
const addrs = this._pullAddresses();
316334
Log.info({ count: addrs.length }, '[BinkP] Scheduled pull cycle starting');
317335
pollNodes(addrs, () => {
318336
polling = false;
@@ -331,13 +349,7 @@ exports.getModule = class BinkpModule extends MessageScanTossModule {
331349
// the same peer turns into one session, not N. After the debounce
332350
// window elapses, dial whatever set of addresses accumulated.
333351
//
334-
_startCrashmailListener(binkpCfg, cb) {
335-
const debounceMs = _.get(
336-
binkpCfg,
337-
'crashmailDebounceMs',
338-
DEFAULT_CRASHMAIL_DEBOUNCE_MS
339-
);
340-
352+
_startCrashmailListener(cb) {
341353
this._crashmailListener = ({ address }) => {
342354
// Drop malformed events: a crashmail entry with an undefined
343355
// net/node would dedupe to "0:undefined/0" and corrupt the
@@ -351,6 +363,13 @@ exports.getModule = class BinkpModule extends MessageScanTossModule {
351363
this._crashmailPending.set(key, address);
352364

353365
if (this._crashmailTimer) return; // window already open
366+
367+
// Read per burst so a reload retunes the coalescing window
368+
const debounceMs = _.get(
369+
Config(),
370+
'scannerTossers.ftn_bso.binkp.crashmailDebounceMs',
371+
DEFAULT_CRASHMAIL_DEBOUNCE_MS
372+
);
354373
this._crashmailTimer = setTimeout(() => {
355374
this._crashmailTimer = null;
356375
const addrs = Array.from(this._crashmailPending.values());
@@ -374,10 +393,75 @@ exports.getModule = class BinkpModule extends MessageScanTossModule {
374393
return cb(null);
375394
}
376395

396+
//
397+
// config.hjson is hot-reloadable. Almost everything this module needs is
398+
// read at the point of use -- per inbound connection, per poll, per
399+
// crashmail burst -- so a reload applies on its own. Two things can't
400+
// work that way and are reconciled here:
401+
//
402+
// * the pull schedule, which is compiled into a timer -> rebuild it
403+
// * the inbound listener bindings, which are baked into a socket ->
404+
// report that a restart is needed rather than silently ignoring them
405+
//
406+
_startConfigWatch(binkpCfg, cb) {
407+
this._listenerConfig = this._listenerSnapshot(binkpCfg);
408+
this._pullSchedule = binkpCfg.pullSchedule;
409+
410+
this._configChangedListener = () => this._applyConfigChange();
411+
Events.addListener(
412+
Events.getSystemEvents().ConfigChanged,
413+
this._configChangedListener
414+
);
415+
return cb(null);
416+
}
417+
418+
_listenerSnapshot(binkpCfg) {
419+
return LISTENER_BOUND_KEYS.reduce((snapshot, key) => {
420+
snapshot[key] = _.get(binkpCfg, key);
421+
return snapshot;
422+
}, {});
423+
}
424+
425+
_applyConfigChange() {
426+
const binkpCfg = _.get(Config(), 'scannerTossers.ftn_bso.binkp', {});
427+
428+
if (binkpCfg.pullSchedule !== this._pullSchedule) {
429+
this._pullSchedule = binkpCfg.pullSchedule;
430+
if (this._pullTimer) {
431+
this._pullTimer.clear();
432+
this._pullTimer = null;
433+
}
434+
// _startPullSchedule logs the outcome, including a disabled or
435+
// unparsable schedule, and never errors
436+
this._startPullSchedule(binkpCfg, () => {});
437+
Log.info(
438+
{ schedule: binkpCfg.pullSchedule || null },
439+
'[BinkP] Pull schedule reloaded'
440+
);
441+
}
442+
443+
// Compared against the boot-time snapshot rather than the previous
444+
// reload: the mismatch stands until the BBS is restarted, so it is
445+
// still worth saying on a later reload.
446+
const current = this._listenerSnapshot(binkpCfg);
447+
const changed = LISTENER_BOUND_KEYS.filter(
448+
key => !_.isEqual(current[key], this._listenerConfig[key])
449+
);
450+
if (changed.length > 0) {
451+
Log.warn(
452+
{ changed },
453+
'[BinkP] Inbound listener settings changed; restart required for them to take effect'
454+
);
455+
}
456+
}
457+
377458
// Build the list of addresses for a pull cycle: every entry in
378459
// binkp.nodes whose pattern parses as a concrete address (not a
379460
// wildcard) and whose config doesn't set `pull: false`.
461+
// |binkpCfg| defaults to the live config; pass one explicitly only to
462+
// evaluate a set of nodes other than what is currently configured.
380463
_pullAddresses(binkpCfg) {
464+
binkpCfg = binkpCfg || _.get(Config(), 'scannerTossers.ftn_bso.binkp', {});
381465
const nodes = binkpCfg.nodes || {};
382466
const out = [];
383467
for (const [pattern, conf] of Object.entries(nodes)) {
@@ -395,10 +479,20 @@ exports.getModule = class BinkpModule extends MessageScanTossModule {
395479
return out;
396480
}
397481

398-
async _handleConnection(socket, spool, addresses, binkpCfg, tempDir) {
482+
async _handleConnection(socket) {
399483
const remote = `${socket.remoteAddress}:${socket.remotePort}`;
400484
Log.info({ remote }, '[BinkP] Inbound connection');
401485

486+
// Resolved per connection, not per process: config.hjson is
487+
// hot-reloadable and a session opened after a reload must honour the
488+
// new spool paths, local addresses, node passwords and FREQ config.
489+
// Read once here so the values can't shift mid-session.
490+
const config = Config();
491+
const binkpCfg = _.get(config, 'scannerTossers.ftn_bso.binkp', {});
492+
const spool = buildSpool(config);
493+
const addresses = localAddresses(config);
494+
const tempDir = _.get(binkpCfg, 'tempDir', os.tmpdir());
495+
402496
const session = new BinkpSession(socket, {
403497
role: 'answering',
404498
addresses,

docs/_docs/messageareas/binkp.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,26 @@ freq: {
242242
243243
With this setup, every new nodelist that arrives via TIC is immediately FREQ-serveable — no path configuration to maintain.
244244

245+
#### Reloading configuration
246+
247+
`config.hjson` is watched and reloaded while the BBS runs. Almost everything under `binkp` is read at the moment it is used, so a reload applies on its own:
248+
249+
| Setting | Applies to | When it takes effect |
250+
|---------|-----------|----------------------|
251+
| `nodes` (hosts, ports, passwords, `pull`, TLS) | next session, inbound or outbound | Immediately |
252+
| `freq` | next inbound session | Immediately |
253+
| `crashmailDebounceMs` | next crashmail burst | Immediately |
254+
| `tempDir`, `staleLockMaxAgeMs` | next session | Immediately |
255+
| `ftn_bso` `paths` and `messageNetworks.ftn.networks` | next session or poll | Immediately |
256+
| `pullSchedule` | the pull timer | On reload — the timer is rebuilt |
257+
| `inbound.enabled`, `inbound.port`, `inbound.address`, `inbound.tls.*` | the listening socket | **Restart required** |
258+
259+
The last row is bound into the socket when it starts listening; changing it under live sessions is not something a reload can do safely. A reload that touches those keys logs a warning naming them so the change isn't silently lost:
260+
261+
```
262+
[BinkP] Inbound listener settings changed; restart required for them to take effect
263+
```
264+
245265
---
246266

247267
### How it works with `ftn_bso`

0 commit comments

Comments
 (0)