@scp-dev/broker 0.2.0 → 0.2.1

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();
@@ -1379,6 +1385,22 @@ async function startBroker(options) {
1379
1385
  }
1380
1386
  for (const id of known.keys()) if (!live.has(id)) known.delete(id);
1381
1387
  }, options.presenceEveryMs ?? PRESENCE_MS);
1388
+ const ownership = setInterval(() => {
1389
+ if (closing || ownsState()) return;
1390
+ log(`relay broker: ${socket} or its lock was removed or taken over; stopping`);
1391
+ void stop();
1392
+ }, options.ownershipEveryMs ?? OWNERSHIP_MS);
1393
+ ownership.unref?.();
1394
+ function ownsSocket() {
1395
+ return lstatSync2(socket, { throwIfNoEntry: false })?.ino === socketIno;
1396
+ }
1397
+ function ownsState() {
1398
+ try {
1399
+ return ownsSocket() && readFileSync6(lock.path, "utf8") === lock.content;
1400
+ } catch {
1401
+ return false;
1402
+ }
1403
+ }
1382
1404
  const remoteHooks = {
1383
1405
  wake,
1384
1406
  fleet: notifyFleet,
@@ -1467,6 +1489,20 @@ async function startBroker(options) {
1467
1489
  function localViews() {
1468
1490
  return core.list({ all: true }).filter((view) => core.machineOf(view.id) === null);
1469
1491
  }
1492
+ function setWaker(s, endpoint) {
1493
+ const was = s.data.waker;
1494
+ if (was === endpoint) return;
1495
+ s.data.waker = endpoint;
1496
+ if (was) core.waker(was, false, { grace: s.data.transient === true });
1497
+ if (endpoint) core.waker(endpoint, true);
1498
+ for (const id of [was, endpoint]) if (id) presenceMoved(id);
1499
+ }
1500
+ function presenceMoved(id) {
1501
+ const view = core.whoami(id);
1502
+ if ((known.get(id) ?? "offline") === view.presence) return false;
1503
+ presenceChanged(view);
1504
+ return true;
1505
+ }
1470
1506
  function presenceChanged(view) {
1471
1507
  known.set(view.id, view.presence);
1472
1508
  notifyFleet({ type: "presence", endpoint: view.address, presence: view.presence, harness: view.harness, labels: view.labels });
@@ -1618,6 +1654,7 @@ async function startBroker(options) {
1618
1654
  s.data.closed = true;
1619
1655
  clearTimeout(s.data.lineTimer);
1620
1656
  connections.delete(s);
1657
+ if (!closing) setWaker(s, void 0);
1621
1658
  if (s.data.endpoint && !closing && !s.data.transient && !s.data.lineage) {
1622
1659
  const before = core.whoami(s.data.endpoint).presence;
1623
1660
  const settled = core.disconnect(s.data.endpoint, { settle: !s.data.brief });
@@ -1911,7 +1948,8 @@ async function startBroker(options) {
1911
1948
  case "wake": {
1912
1949
  if (s.data.lineage && !s.data.own) throw new RelayError3("unauthorized", "only the endpoint's own session says how it wakes");
1913
1950
  const changed = core.setWake(me, params.via, hookOf(s));
1914
- if (changed) bridge?.changed(me);
1951
+ setWaker(s, params.via !== "none" && core.whoami(me).wake === params.via ? me : void 0);
1952
+ if (!presenceMoved(me) && changed) bridge?.changed(me);
1915
1953
  return { changed };
1916
1954
  }
1917
1955
  case "rename": {
@@ -2214,6 +2252,7 @@ async function startBroker(options) {
2214
2252
  closing = true;
2215
2253
  clearInterval(sweeper);
2216
2254
  clearInterval(presenceTimer);
2255
+ clearInterval(ownership);
2217
2256
  await bridge?.stop();
2218
2257
  if (idleTimer) clearTimeout(idleTimer);
2219
2258
  for (const set of [...waiters.values()]) for (const fn of [...set]) fn();
@@ -2221,11 +2260,13 @@ async function startBroker(options) {
2221
2260
  while (pending > 0 && Date.now() < deadline) await sleep2(5);
2222
2261
  await Promise.race([judge.settled(), sleep2(Math.max(0, deadline - Date.now()))]);
2223
2262
  for (const s of connections) s.end();
2224
- listener.close();
2225
- try {
2226
- unlinkSync2(socket);
2227
- } catch {
2228
- }
2263
+ if (ownsSocket()) {
2264
+ listener.close();
2265
+ try {
2266
+ unlinkSync2(socket);
2267
+ } catch {
2268
+ }
2269
+ } else listener.unref();
2229
2270
  if (socket !== join4(dir, "relay.sock")) {
2230
2271
  try {
2231
2272
  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.2.1",
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.1.6",
41
+ "@scp-dev/hub": "0.5.1"
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()
@@ -294,6 +302,26 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
294
302
  }
295
303
  for (const id of known.keys()) if (!live.has(id)) known.delete(id)
296
304
  }, options.presenceEveryMs ?? PRESENCE_MS)
305
+ const ownership = setInterval(() => {
306
+ if (closing || ownsState()) return
307
+ log(`relay broker: ${socket} or its lock was removed or taken over; stopping`)
308
+ void stop()
309
+ }, options.ownershipEveryMs ?? OWNERSHIP_MS)
310
+ ownership.unref?.()
311
+
312
+ /** whether the socket path is still this broker's listener */
313
+ function ownsSocket(): boolean {
314
+ return lstatSync(socket, { throwIfNoEntry: false })?.ino === socketIno
315
+ }
316
+
317
+ /** whether the socket path and the lock are still this broker's */
318
+ function ownsState(): boolean {
319
+ try {
320
+ return ownsSocket() && readFileSync(lock.path, "utf8") === lock.content
321
+ } catch {
322
+ return false
323
+ }
324
+ }
297
325
  const remoteHooks: BridgeHooks = {
298
326
  wake,
299
327
  fleet: notifyFleet,
@@ -399,6 +427,24 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
399
427
  return core.list({ all: true }).filter((view) => core.machineOf(view.id) === null)
400
428
  }
401
429
 
430
+ /** counts `s` as the waker of `endpoint`, or of none, and reports the presence that moves */
431
+ function setWaker(s: Conn<Connection>, endpoint: string | undefined) {
432
+ const was = s.data.waker
433
+ if (was === endpoint) return
434
+ s.data.waker = endpoint
435
+ if (was) core.waker(was, false, { grace: s.data.transient === true })
436
+ if (endpoint) core.waker(endpoint, true)
437
+ for (const id of [was, endpoint]) if (id) presenceMoved(id)
438
+ }
439
+
440
+ /** reports an endpoint's presence when it is not the one last reported; whether it was not */
441
+ function presenceMoved(id: string): boolean {
442
+ const view = core.whoami(id)
443
+ if ((known.get(id) ?? "offline") === view.presence) return false
444
+ presenceChanged(view)
445
+ return true
446
+ }
447
+
402
448
  /** an endpoint's presence changed outside a connection's own hello or close: watchers and the other machines hear it */
403
449
  function presenceChanged(view: EndpointView) {
404
450
  known.set(view.id, view.presence)
@@ -583,6 +629,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
583
629
  s.data.closed = true
584
630
  clearTimeout(s.data.lineTimer)
585
631
  connections.delete(s)
632
+ if (!closing) setWaker(s, undefined)
586
633
  if (s.data.endpoint && !closing && !s.data.transient && !s.data.lineage) {
587
634
  const before = core.whoami(s.data.endpoint).presence
588
635
  const settled = core.disconnect(s.data.endpoint, { settle: !s.data.brief })
@@ -891,7 +938,8 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
891
938
  case "wake": {
892
939
  if (s.data.lineage && !s.data.own) throw new RelayError("unauthorized", "only the endpoint's own session says how it wakes")
893
940
  const changed = core.setWake(me, params.via, hookOf(s))
894
- if (changed) bridge?.changed(me)
941
+ setWaker(s, params.via !== "none" && core.whoami(me).wake === params.via ? me : undefined)
942
+ if (!presenceMoved(me) && changed) bridge?.changed(me)
895
943
  return { changed }
896
944
  }
897
945
  case "rename": {
@@ -1210,6 +1258,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
1210
1258
  closing = true
1211
1259
  clearInterval(sweeper)
1212
1260
  clearInterval(presenceTimer)
1261
+ clearInterval(ownership)
1213
1262
  await bridge?.stop()
1214
1263
  if (idleTimer) clearTimeout(idleTimer)
1215
1264
  for (const set of [...waiters.values()]) for (const fn of [...set]) fn()
@@ -1217,10 +1266,13 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
1217
1266
  while (pending > 0 && Date.now() < deadline) await sleep(5)
1218
1267
  await Promise.race([judge.settled(), sleep(Math.max(0, deadline - Date.now()))])
1219
1268
  for (const s of connections) s.end()
1220
- listener.close()
1221
- try {
1222
- unlinkSync(socket)
1223
- } catch {}
1269
+ // closing a listener unlinks its path, which another broker may own by now
1270
+ if (ownsSocket()) {
1271
+ listener.close()
1272
+ try {
1273
+ unlinkSync(socket)
1274
+ } catch {}
1275
+ } else listener.unref()
1224
1276
  if (socket !== join(dir, "relay.sock")) {
1225
1277
  try {
1226
1278
  rmdirSync(dirname(socket))