@scp-dev/broker 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -334,6 +334,8 @@ interface BrokerOptions {
334
334
  link?: LinkFactory | undefined;
335
335
  /** how often presence that decays without a request (a hook-only endpoint) is checked for changes (default 60 s) */
336
336
  presenceEveryMs?: number | undefined;
337
+ /** how often the broker checks it still owns its socket and lock, stopping once another broker or a wiped state directory took them (default 2 s) */
338
+ ownershipEveryMs?: number | undefined;
337
339
  /** where a judge model's key_env is read (default the broker's environment) */
338
340
  env?: Record<string, string | undefined> | undefined;
339
341
  /** the judge model client for a policy's `judge.model` (default the provider's HTTP API) */
package/dist/index.js CHANGED
@@ -706,7 +706,8 @@ function startBridge(options) {
706
706
  byMachine.set(view.machine, [...byMachine.get(view.machine) ?? [], view]);
707
707
  }
708
708
  if (full) {
709
- for (const view of core.remoteViews()) if (view.machine !== account && !byMachine.has(view.machine)) byMachine.set(view.machine, []);
709
+ core.dropMachine(machine);
710
+ for (const view of core.remoteViews()) if (view.machine !== account && !byMachine.has(view.machine)) core.dropMachine(view.machine);
710
711
  }
711
712
  for (const [name, list] of byMachine) {
712
713
  try {
@@ -807,7 +808,8 @@ function startBridge(options) {
807
808
  if (typeof params.machine !== "string" || params.machine === machine) return;
808
809
  if (typeof params.own === "boolean") core.setOther(params.machine, !params.own);
809
810
  try {
810
- core.mirror(params.machine, params.endpoints, { full: params.full === true });
811
+ if (params.released === true) core.dropMachine(params.machine);
812
+ else core.mirror(params.machine, params.endpoints, { full: params.full === true });
811
813
  } catch (error) {
812
814
  log(`relay bridge: the endpoints of ${params.machine} were not applied: ${error instanceof Error ? error.message : String(error)}`);
813
815
  }
@@ -1224,6 +1226,7 @@ function openDatabase(path) {
1224
1226
  // src/server.ts
1225
1227
  var SWEEP_MS = 10 * 6e4;
1226
1228
  var PRESENCE_MS = 6e4;
1229
+ var OWNERSHIP_MS = 2e3;
1227
1230
  var OBSERVER_METHODS = /* @__PURE__ */ new Set(["list", "snapshot", "watch", "log", "policy", "policy_check", "cloud_status", "bridge_relink"]);
1228
1231
  var SCOPE_METHODS = /* @__PURE__ */ new Set([
1229
1232
  "rules",
@@ -1304,6 +1307,7 @@ async function startBroker(options) {
1304
1307
  let judge;
1305
1308
  let bridge = null;
1306
1309
  let listener;
1310
+ let socketIno;
1307
1311
  try {
1308
1312
  privateDir(dirname2(socket));
1309
1313
  db = openDatabase(join4(dir, "relay.db"));
@@ -1320,6 +1324,7 @@ async function startBroker(options) {
1320
1324
  alive: running
1321
1325
  });
1322
1326
  core.migrate();
1327
+ core.relabelMachine();
1323
1328
  core.resetConnections();
1324
1329
  core.sweep();
1325
1330
  judge = new JudgeGate({ sql, core, now: options.now ?? Date.now, env: options.env ?? process.env, client: options.judgeClient, log, wake });
@@ -1356,6 +1361,7 @@ async function startBroker(options) {
1356
1361
  });
1357
1362
  listener.on("error", (error) => log(`relay broker: the socket listener failed: ${error.message}`));
1358
1363
  chmodSync2(socket, 384);
1364
+ socketIno = lstatSync2(socket).ino;
1359
1365
  } catch (error) {
1360
1366
  listener?.close();
1361
1367
  db?.close();
@@ -1369,16 +1375,39 @@ async function startBroker(options) {
1369
1375
  judge.sweep();
1370
1376
  }, SWEEP_MS);
1371
1377
  const known = /* @__PURE__ */ new Map();
1372
- for (const view of localViews()) known.set(view.id, view.presence);
1378
+ const wakes = /* @__PURE__ */ new Map();
1379
+ for (const view of localViews()) {
1380
+ known.set(view.id, view.presence);
1381
+ wakes.set(view.id, view.wake);
1382
+ }
1373
1383
  const presenceTimer = setInterval(() => {
1374
1384
  core.endExited();
1375
1385
  const live = /* @__PURE__ */ new Set();
1376
1386
  for (const view of localViews()) {
1377
1387
  live.add(view.id);
1378
1388
  if ((known.get(view.id) ?? "offline") !== view.presence) presenceChanged(view);
1389
+ else if (wakes.has(view.id) && wakes.get(view.id) !== view.wake) bridge?.changed(view.id);
1390
+ wakes.set(view.id, view.wake);
1379
1391
  }
1380
1392
  for (const id of known.keys()) if (!live.has(id)) known.delete(id);
1393
+ for (const id of wakes.keys()) if (!live.has(id)) wakes.delete(id);
1381
1394
  }, options.presenceEveryMs ?? PRESENCE_MS);
1395
+ const ownership = setInterval(() => {
1396
+ if (closing || ownsState()) return;
1397
+ log(`relay broker: ${socket} or its lock was removed or taken over; stopping`);
1398
+ void stop();
1399
+ }, options.ownershipEveryMs ?? OWNERSHIP_MS);
1400
+ ownership.unref?.();
1401
+ function ownsSocket() {
1402
+ return lstatSync2(socket, { throwIfNoEntry: false })?.ino === socketIno;
1403
+ }
1404
+ function ownsState() {
1405
+ try {
1406
+ return ownsSocket() && readFileSync6(lock.path, "utf8") === lock.content;
1407
+ } catch {
1408
+ return false;
1409
+ }
1410
+ }
1382
1411
  const remoteHooks = {
1383
1412
  wake,
1384
1413
  fleet: notifyFleet,
@@ -1467,6 +1496,26 @@ async function startBroker(options) {
1467
1496
  function localViews() {
1468
1497
  return core.list({ all: true }).filter((view) => core.machineOf(view.id) === null);
1469
1498
  }
1499
+ function setWaker(s, endpoint) {
1500
+ const was = s.data.waker;
1501
+ if (was === endpoint) return;
1502
+ s.data.waker = endpoint;
1503
+ if (was) {
1504
+ const wake2 = core.whoami(was).wake;
1505
+ core.waker(was, false, { grace: s.data.transient === true });
1506
+ if (!presenceMoved(was) && core.whoami(was).wake !== wake2) bridge?.changed(was);
1507
+ }
1508
+ if (endpoint) {
1509
+ core.waker(endpoint, true);
1510
+ presenceMoved(endpoint);
1511
+ }
1512
+ }
1513
+ function presenceMoved(id) {
1514
+ const view = core.whoami(id);
1515
+ if ((known.get(id) ?? "offline") === view.presence) return false;
1516
+ presenceChanged(view);
1517
+ return true;
1518
+ }
1470
1519
  function presenceChanged(view) {
1471
1520
  known.set(view.id, view.presence);
1472
1521
  notifyFleet({ type: "presence", endpoint: view.address, presence: view.presence, harness: view.harness, labels: view.labels });
@@ -1618,6 +1667,7 @@ async function startBroker(options) {
1618
1667
  s.data.closed = true;
1619
1668
  clearTimeout(s.data.lineTimer);
1620
1669
  connections.delete(s);
1670
+ if (!closing) setWaker(s, void 0);
1621
1671
  if (s.data.endpoint && !closing && !s.data.transient && !s.data.lineage) {
1622
1672
  const before = core.whoami(s.data.endpoint).presence;
1623
1673
  const settled = core.disconnect(s.data.endpoint, { settle: !s.data.brief });
@@ -1911,7 +1961,8 @@ async function startBroker(options) {
1911
1961
  case "wake": {
1912
1962
  if (s.data.lineage && !s.data.own) throw new RelayError3("unauthorized", "only the endpoint's own session says how it wakes");
1913
1963
  const changed = core.setWake(me, params.via, hookOf(s));
1914
- if (changed) bridge?.changed(me);
1964
+ setWaker(s, params.via !== "none" && core.whoami(me).wake === params.via ? me : void 0);
1965
+ if (!presenceMoved(me) && changed) bridge?.changed(me);
1915
1966
  return { changed };
1916
1967
  }
1917
1968
  case "rename": {
@@ -1948,11 +1999,13 @@ async function startBroker(options) {
1948
1999
  return view;
1949
2000
  }
1950
2001
  case "event": {
2002
+ const wake2 = core.whoami(me).wake;
1951
2003
  const row = core.event(me, params, hookOf(s));
1952
2004
  notifyFleet({ type: "activity", ...row });
1953
2005
  bridge?.activity(me, row);
1954
2006
  const after = core.whoami(me);
1955
2007
  if ((known.get(me) ?? "offline") !== after.presence) presenceChanged(after);
2008
+ else if (after.wake !== wake2) bridge?.changed(me);
1956
2009
  return row;
1957
2010
  }
1958
2011
  case "inject": {
@@ -2214,6 +2267,7 @@ async function startBroker(options) {
2214
2267
  closing = true;
2215
2268
  clearInterval(sweeper);
2216
2269
  clearInterval(presenceTimer);
2270
+ clearInterval(ownership);
2217
2271
  await bridge?.stop();
2218
2272
  if (idleTimer) clearTimeout(idleTimer);
2219
2273
  for (const set of [...waiters.values()]) for (const fn of [...set]) fn();
@@ -2221,11 +2275,13 @@ async function startBroker(options) {
2221
2275
  while (pending > 0 && Date.now() < deadline) await sleep2(5);
2222
2276
  await Promise.race([judge.settled(), sleep2(Math.max(0, deadline - Date.now()))]);
2223
2277
  for (const s of connections) s.end();
2224
- listener.close();
2225
- try {
2226
- unlinkSync2(socket);
2227
- } catch {
2228
- }
2278
+ if (ownsSocket()) {
2279
+ listener.close();
2280
+ try {
2281
+ unlinkSync2(socket);
2282
+ } catch {
2283
+ }
2284
+ } else listener.unref();
2229
2285
  if (socket !== join4(dir, "relay.sock")) {
2230
2286
  try {
2231
2287
  rmdirSync(dirname2(socket));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scp-dev/broker",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Swarm Communication Protocol local broker: a unix socket JSON-RPC daemon over the relay core, its client and the bridge to an account hub.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://scp.dev",
@@ -37,8 +37,8 @@
37
37
  "typecheck": "tsc -p ."
38
38
  },
39
39
  "dependencies": {
40
- "@scp-dev/core": "0.1.5",
41
- "@scp-dev/hub": "0.5.0"
40
+ "@scp-dev/core": "0.2.0",
41
+ "@scp-dev/hub": "0.6.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "tsup": "^8.5.1"
package/src/bridge.ts CHANGED
@@ -300,14 +300,20 @@ export function startBridge(options: BridgeOptions): Bridge {
300
300
  for (const fn of waitingForLink.splice(0)) fn()
301
301
  }
302
302
 
303
- /** the other machines' endpoints, grouped by machine; with `full`, a known machine missing from the list goes offline */
303
+ /**
304
+ * the other machines' endpoints, grouped by machine; with `full`, a known machine the hub no longer lists was released and its rows go,
305
+ * as do rows mirrored under this machine's own name from before it was renamed back to it
306
+ */
304
307
  function applyRemote(views: RemoteView[], full: boolean) {
305
308
  const byMachine = new Map<string, RemoteView[]>()
306
309
  for (const view of views) {
307
310
  if (view.machine === machine) continue
308
311
  byMachine.set(view.machine, [...(byMachine.get(view.machine) ?? []), view])
309
312
  }
310
- if (full) for (const view of core.remoteViews()) if (view.machine !== account && !byMachine.has(view.machine)) byMachine.set(view.machine, [])
313
+ if (full) {
314
+ core.dropMachine(machine)
315
+ for (const view of core.remoteViews()) if (view.machine !== account && !byMachine.has(view.machine)) core.dropMachine(view.machine)
316
+ }
311
317
  for (const [name, list] of byMachine) {
312
318
  try {
313
319
  core.mirror(name, list, { full })
@@ -423,7 +429,8 @@ export function startBridge(options: BridgeOptions): Bridge {
423
429
  if (typeof params.machine !== "string" || params.machine === machine) return
424
430
  if (typeof params.own === "boolean") core.setOther(params.machine, !params.own)
425
431
  try {
426
- core.mirror(params.machine, params.endpoints, { full: params.full === true })
432
+ if (params.released === true) core.dropMachine(params.machine)
433
+ else core.mirror(params.machine, params.endpoints, { full: params.full === true })
427
434
  } catch (error) {
428
435
  log(`relay bridge: the endpoints of ${params.machine} were not applied: ${error instanceof Error ? error.message : String(error)}`)
429
436
  }
package/src/server.ts CHANGED
@@ -64,6 +64,8 @@ export interface BrokerOptions {
64
64
  link?: LinkFactory | undefined
65
65
  /** how often presence that decays without a request (a hook-only endpoint) is checked for changes (default 60 s) */
66
66
  presenceEveryMs?: number | undefined
67
+ /** how often the broker checks it still owns its socket and lock, stopping once another broker or a wiped state directory took them (default 2 s) */
68
+ ownershipEveryMs?: number | undefined
67
69
  /** where a judge model's key_env is read (default the broker's environment) */
68
70
  env?: Record<string, string | undefined> | undefined
69
71
  /** the judge model client for a policy's `judge.model` (default the provider's HTTP API) */
@@ -114,6 +116,8 @@ interface Connection {
114
116
  process?: string
115
117
  /** a one-shot CLI command's connection: its comings and goings are not fleet presence events */
116
118
  brief?: boolean
119
+ /** the endpoint whose wake this connection set: it counts as its waker until it closes or sets none */
120
+ waker?: string | undefined
117
121
  cwd?: string | undefined
118
122
  watching: boolean
119
123
  closed: boolean
@@ -126,6 +130,7 @@ interface Connection {
126
130
 
127
131
  const SWEEP_MS = 10 * 60_000
128
132
  const PRESENCE_MS = 60_000
133
+ const OWNERSHIP_MS = 2_000
129
134
  const OBSERVER_METHODS = new Set(["list", "snapshot", "watch", "log", "policy", "policy_check", "cloud_status", "bridge_relink"])
130
135
  const SCOPE_METHODS = new Set([
131
136
  "rules",
@@ -219,6 +224,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
219
224
  let judge!: JudgeGate
220
225
  let bridge: Bridge | null = null
221
226
  let listener!: Server
227
+ let socketIno: number | bigint | undefined
222
228
  try {
223
229
  privateDir(dirname(socket))
224
230
  db = openDatabase(join(dir, "relay.db"))
@@ -235,6 +241,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
235
241
  alive: running,
236
242
  })
237
243
  core.migrate()
244
+ core.relabelMachine()
238
245
  core.resetConnections()
239
246
  core.sweep()
240
247
  judge = new JudgeGate({ sql, core, now: options.now ?? Date.now, env: options.env ?? process.env, client: options.judgeClient, log, wake })
@@ -271,6 +278,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
271
278
  })
272
279
  listener.on("error", (error) => log(`relay broker: the socket listener failed: ${error.message}`))
273
280
  chmodSync(socket, 0o600)
281
+ socketIno = lstatSync(socket).ino
274
282
  } catch (error) {
275
283
  listener?.close()
276
284
  db?.close()
@@ -284,16 +292,43 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
284
292
  judge.sweep()
285
293
  }, SWEEP_MS)
286
294
  const known = new Map<string, Presence>()
287
- for (const view of localViews()) known.set(view.id, view.presence)
295
+ const wakes = new Map<string, string | null | undefined>()
296
+ for (const view of localViews()) {
297
+ known.set(view.id, view.presence)
298
+ wakes.set(view.id, view.wake)
299
+ }
288
300
  const presenceTimer = setInterval(() => {
289
301
  core.endExited()
290
302
  const live = new Set<string>()
291
303
  for (const view of localViews()) {
292
304
  live.add(view.id)
293
305
  if ((known.get(view.id) ?? "offline") !== view.presence) presenceChanged(view)
306
+ else if (wakes.has(view.id) && wakes.get(view.id) !== view.wake) bridge?.changed(view.id)
307
+ wakes.set(view.id, view.wake)
294
308
  }
295
309
  for (const id of known.keys()) if (!live.has(id)) known.delete(id)
310
+ for (const id of wakes.keys()) if (!live.has(id)) wakes.delete(id)
296
311
  }, options.presenceEveryMs ?? PRESENCE_MS)
312
+ const ownership = setInterval(() => {
313
+ if (closing || ownsState()) return
314
+ log(`relay broker: ${socket} or its lock was removed or taken over; stopping`)
315
+ void stop()
316
+ }, options.ownershipEveryMs ?? OWNERSHIP_MS)
317
+ ownership.unref?.()
318
+
319
+ /** whether the socket path is still this broker's listener */
320
+ function ownsSocket(): boolean {
321
+ return lstatSync(socket, { throwIfNoEntry: false })?.ino === socketIno
322
+ }
323
+
324
+ /** whether the socket path and the lock are still this broker's */
325
+ function ownsState(): boolean {
326
+ try {
327
+ return ownsSocket() && readFileSync(lock.path, "utf8") === lock.content
328
+ } catch {
329
+ return false
330
+ }
331
+ }
297
332
  const remoteHooks: BridgeHooks = {
298
333
  wake,
299
334
  fleet: notifyFleet,
@@ -399,6 +434,30 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
399
434
  return core.list({ all: true }).filter((view) => core.machineOf(view.id) === null)
400
435
  }
401
436
 
437
+ /** counts `s` as the waker of `endpoint`, or of none, and reports the presence that moves */
438
+ function setWaker(s: Conn<Connection>, endpoint: string | undefined) {
439
+ const was = s.data.waker
440
+ if (was === endpoint) return
441
+ s.data.waker = endpoint
442
+ if (was) {
443
+ const wake = core.whoami(was).wake
444
+ core.waker(was, false, { grace: s.data.transient === true })
445
+ if (!presenceMoved(was) && core.whoami(was).wake !== wake) bridge?.changed(was)
446
+ }
447
+ if (endpoint) {
448
+ core.waker(endpoint, true)
449
+ presenceMoved(endpoint)
450
+ }
451
+ }
452
+
453
+ /** reports an endpoint's presence when it is not the one last reported; whether it was not */
454
+ function presenceMoved(id: string): boolean {
455
+ const view = core.whoami(id)
456
+ if ((known.get(id) ?? "offline") === view.presence) return false
457
+ presenceChanged(view)
458
+ return true
459
+ }
460
+
402
461
  /** an endpoint's presence changed outside a connection's own hello or close: watchers and the other machines hear it */
403
462
  function presenceChanged(view: EndpointView) {
404
463
  known.set(view.id, view.presence)
@@ -583,6 +642,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
583
642
  s.data.closed = true
584
643
  clearTimeout(s.data.lineTimer)
585
644
  connections.delete(s)
645
+ if (!closing) setWaker(s, undefined)
586
646
  if (s.data.endpoint && !closing && !s.data.transient && !s.data.lineage) {
587
647
  const before = core.whoami(s.data.endpoint).presence
588
648
  const settled = core.disconnect(s.data.endpoint, { settle: !s.data.brief })
@@ -891,7 +951,8 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
891
951
  case "wake": {
892
952
  if (s.data.lineage && !s.data.own) throw new RelayError("unauthorized", "only the endpoint's own session says how it wakes")
893
953
  const changed = core.setWake(me, params.via, hookOf(s))
894
- if (changed) bridge?.changed(me)
954
+ setWaker(s, params.via !== "none" && core.whoami(me).wake === params.via ? me : undefined)
955
+ if (!presenceMoved(me) && changed) bridge?.changed(me)
895
956
  return { changed }
896
957
  }
897
958
  case "rename": {
@@ -928,11 +989,13 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
928
989
  return view
929
990
  }
930
991
  case "event": {
992
+ const wake = core.whoami(me).wake
931
993
  const row = core.event(me, params, hookOf(s))
932
994
  notifyFleet({ type: "activity", ...row })
933
995
  bridge?.activity(me, row)
934
996
  const after = core.whoami(me)
935
997
  if ((known.get(me) ?? "offline") !== after.presence) presenceChanged(after)
998
+ else if (after.wake !== wake) bridge?.changed(me)
936
999
  return row
937
1000
  }
938
1001
  case "inject": {
@@ -1210,6 +1273,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
1210
1273
  closing = true
1211
1274
  clearInterval(sweeper)
1212
1275
  clearInterval(presenceTimer)
1276
+ clearInterval(ownership)
1213
1277
  await bridge?.stop()
1214
1278
  if (idleTimer) clearTimeout(idleTimer)
1215
1279
  for (const set of [...waiters.values()]) for (const fn of [...set]) fn()
@@ -1217,10 +1281,13 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
1217
1281
  while (pending > 0 && Date.now() < deadline) await sleep(5)
1218
1282
  await Promise.race([judge.settled(), sleep(Math.max(0, deadline - Date.now()))])
1219
1283
  for (const s of connections) s.end()
1220
- listener.close()
1221
- try {
1222
- unlinkSync(socket)
1223
- } catch {}
1284
+ // closing a listener unlinks its path, which another broker may own by now
1285
+ if (ownsSocket()) {
1286
+ listener.close()
1287
+ try {
1288
+ unlinkSync(socket)
1289
+ } catch {}
1290
+ } else listener.unref()
1224
1291
  if (socket !== join(dir, "relay.sock")) {
1225
1292
  try {
1226
1293
  rmdirSync(dirname(socket))