@decentnetwork/beagle 0.1.87 → 0.1.88

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/cli.js CHANGED
@@ -145,6 +145,12 @@ async function main() {
145
145
  makeEmbedded: openEmbedded,
146
146
  makeDaemon: () => makeDaemonHost(decentlanCarrierDir(args.configDir)),
147
147
  onChange: (state) => console.log(`[LAN] backend is now: ${state}`),
148
+ // `--backend` has to reach the watcher, or it only ever pinned the FIRST
149
+ // choice: this wrapper re-detected on a 1500ms timer and overrode it a
150
+ // second and a half later. That is why `--backend embedded` did not
151
+ // protect Beagle Desktop on Windows, and why `--backend daemon` on 245
152
+ // still fell back and took the identity lock.
153
+ force: args.backend,
148
154
  });
149
155
  // If this machine also runs a dora, surface its allocation table.
150
156
  const doraCandidates = args.doraDir
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.86";
1
+ window.__DK_UI_VERSION="0.1.87";
2
2
  "use strict";
3
3
  (() => {
4
4
  // packages/beagle-ui/dist/beagle-ui.js
@@ -561,6 +561,9 @@ window.__DK_UI_VERSION="0.1.86";
561
561
  const ta = a.lastTs || 0, tb = b.lastTs || 0;
562
562
  if (ta !== tb)
563
563
  return tb - ta;
564
+ const aa = a.acceptedAt || 0, ba = b.acceptedAt || 0;
565
+ if (aa !== ba)
566
+ return ba - aa;
564
567
  return String(a.alias || a.userId || "").localeCompare(String(b.alias || b.userId || ""));
565
568
  });
566
569
  const online = peers.filter((p) => p.online).length;
@@ -103,12 +103,18 @@ export class EmbeddedHost {
103
103
  // Infinity - Infinity = NaN, and the tie-break would only still work
104
104
  // by accident of NaN being falsy.
105
105
  last.has(f.carrierId) ? -last.get(f.carrierId).ts : Number.MAX_SAFE_INTEGER,
106
+ // Among friends you have never messaged, NEWEST FRIENDSHIP first.
107
+ // Alphabetical put someone you accepted ten seconds ago wherever their
108
+ // name happened to land — usually far down a 50-name list, so accepting
109
+ // a request looked like nothing had happened. The one friend you are
110
+ // most likely to open is the one you just added.
111
+ -(f.acceptedAt ?? 0),
106
112
  (f.name || f.carrierId).toLowerCase(),
107
113
  ];
108
114
  return [...friends].sort((a, b) => {
109
- const [ap, at, an] = rank(a);
110
- const [bp, bt, bn] = rank(b);
111
- return ap - bp || at - bt || an.localeCompare(bn);
115
+ const [ap, at, aa, an] = rank(a);
116
+ const [bp, bt, ba, bn] = rank(b);
117
+ return ap - bp || at - bt || aa - ba || an.localeCompare(bn);
112
118
  });
113
119
  }
114
120
  get downloadsDir() {
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export { startBeagleServer, type BeagleServerOptions } from "./server.js";
2
2
  export { openPeerHost, defaultConfigDir, decentlanCarrierDir, type PeerHost, type BackendKind } from "./peer-host.js";
3
- export { ipcCall, ipcSocketPath, daemonIsRunning, type IpcRequest, type IpcResponse, type IpcOp } from "./ipc.js";
3
+ export { ipcCall, ipcSocketPath, probeDaemon, daemonSocketMayExist, type IpcRequest, type IpcResponse, type IpcOp } from "./ipc.js";
package/dist/index.js CHANGED
@@ -2,4 +2,4 @@
2
2
  // server and the backend abstraction are exported so it can be embedded.
3
3
  export { startBeagleServer } from "./server.js";
4
4
  export { openPeerHost, defaultConfigDir, decentlanCarrierDir } from "./peer-host.js";
5
- export { ipcCall, ipcSocketPath, daemonIsRunning } from "./ipc.js";
5
+ export { ipcCall, ipcSocketPath, probeDaemon, daemonSocketMayExist } from "./ipc.js";
package/dist/ipc.d.ts CHANGED
@@ -43,6 +43,39 @@ export declare function ipcSocketPath(dataDir: string, platform?: NodeJS.Platfor
43
43
  /** fs.existsSync is meaningful for Unix-domain sockets but always false for
44
44
  * Windows named pipes, so on Windows we connect and let net report. */
45
45
  export declare function ipcSocketSupportsFsExistenceCheck(platform?: NodeJS.Platform): boolean;
46
- export declare function daemonIsRunning(dataDir: string): boolean;
46
+ /** How long the liveness probe waits for a connect before calling it dead.
47
+ * Local IPC: a listening socket accepts in microseconds, so this is a
48
+ * generous ceiling that still keeps the 1500ms watch tick honest. */
49
+ export declare const DAEMON_PROBE_TIMEOUT_MS = 400;
50
+ /**
51
+ * Is a decentlan daemon actually LISTENING for this identity?
52
+ *
53
+ * The only trustworthy answer is a connection. The previous check asked the
54
+ * filesystem, which cannot answer on either platform:
55
+ *
56
+ * - Windows named pipes never appear to `existsSync`, so the old code
57
+ * returned a hard-coded `true` — "can't tell; try it". Every beagle on
58
+ * Windows therefore believed in a daemon that was not there, handed its
59
+ * identity to a pipe nobody served, and failed every peer op with
60
+ * `connect ENOENT \\.\pipe\agentnet-…`. That one wrong default is why
61
+ * add-friend, renaming and .beagles.eth registration were all broken.
62
+ * - On Unix a `daemon.sock` left behind by a crashed daemon exists just as
63
+ * firmly as a live one, so the same promotion happened there too, only
64
+ * rarer.
65
+ *
66
+ * Connecting costs a round trip to a local socket and removes both. It is the
67
+ * same code on every platform, which is the real win: no branch that only one
68
+ * of us can test.
69
+ */
70
+ export declare function probeDaemon(dataDir: string, timeoutMs?: number): Promise<boolean>;
71
+ /**
72
+ * Sync, best-effort: could a daemon socket be there?
73
+ *
74
+ * Only for callers that genuinely cannot await. It answers `false` when it
75
+ * cannot tell — the opposite of the old default, and the whole point. A `true`
76
+ * from this is still only a hint; confirm with {@link probeDaemon} before
77
+ * handing an identity to anything.
78
+ */
79
+ export declare function daemonSocketMayExist(dataDir: string): boolean;
47
80
  /** One request, one newline-delimited JSON response. */
48
81
  export declare function ipcCall(dataDir: string, req: IpcRequest, timeoutMs?: number): Promise<IpcResponse>;
package/dist/ipc.js CHANGED
@@ -27,9 +27,65 @@ export function ipcSocketPath(dataDir, platform = process.platform) {
27
27
  export function ipcSocketSupportsFsExistenceCheck(platform = process.platform) {
28
28
  return platform !== "win32";
29
29
  }
30
- export function daemonIsRunning(dataDir) {
30
+ /** How long the liveness probe waits for a connect before calling it dead.
31
+ * Local IPC: a listening socket accepts in microseconds, so this is a
32
+ * generous ceiling that still keeps the 1500ms watch tick honest. */
33
+ export const DAEMON_PROBE_TIMEOUT_MS = 400;
34
+ /**
35
+ * Is a decentlan daemon actually LISTENING for this identity?
36
+ *
37
+ * The only trustworthy answer is a connection. The previous check asked the
38
+ * filesystem, which cannot answer on either platform:
39
+ *
40
+ * - Windows named pipes never appear to `existsSync`, so the old code
41
+ * returned a hard-coded `true` — "can't tell; try it". Every beagle on
42
+ * Windows therefore believed in a daemon that was not there, handed its
43
+ * identity to a pipe nobody served, and failed every peer op with
44
+ * `connect ENOENT \\.\pipe\agentnet-…`. That one wrong default is why
45
+ * add-friend, renaming and .beagles.eth registration were all broken.
46
+ * - On Unix a `daemon.sock` left behind by a crashed daemon exists just as
47
+ * firmly as a live one, so the same promotion happened there too, only
48
+ * rarer.
49
+ *
50
+ * Connecting costs a round trip to a local socket and removes both. It is the
51
+ * same code on every platform, which is the real win: no branch that only one
52
+ * of us can test.
53
+ */
54
+ export function probeDaemon(dataDir, timeoutMs = DAEMON_PROBE_TIMEOUT_MS) {
55
+ const sockPath = ipcSocketPath(dataDir);
56
+ // A cheap negative where the filesystem CAN answer. Never a positive: an
57
+ // existing socket file is not a listening daemon, which was the second bug.
58
+ if (ipcSocketSupportsFsExistenceCheck() && !existsSync(sockPath)) {
59
+ return Promise.resolve(false);
60
+ }
61
+ return new Promise((resolve) => {
62
+ const sock = createConnection(sockPath);
63
+ let settled = false;
64
+ const finish = (alive) => {
65
+ if (settled)
66
+ return;
67
+ settled = true;
68
+ clearTimeout(timer);
69
+ sock.destroy();
70
+ resolve(alive);
71
+ };
72
+ const timer = setTimeout(() => finish(false), timeoutMs);
73
+ timer.unref?.();
74
+ sock.on("connect", () => finish(true));
75
+ sock.on("error", () => finish(false));
76
+ });
77
+ }
78
+ /**
79
+ * Sync, best-effort: could a daemon socket be there?
80
+ *
81
+ * Only for callers that genuinely cannot await. It answers `false` when it
82
+ * cannot tell — the opposite of the old default, and the whole point. A `true`
83
+ * from this is still only a hint; confirm with {@link probeDaemon} before
84
+ * handing an identity to anything.
85
+ */
86
+ export function daemonSocketMayExist(dataDir) {
31
87
  if (!ipcSocketSupportsFsExistenceCheck())
32
- return true; // can't tell; try it
88
+ return false;
33
89
  return existsSync(ipcSocketPath(dataDir));
34
90
  }
35
91
  /** One request, one newline-delimited JSON response. */
@@ -1,7 +1,11 @@
1
1
  import { type IpcRequest, type IpcResponse } from "./ipc.js";
2
2
  import { Logger } from "./logger.js";
3
- import type { PeerHost } from "./peer-host.js";
4
- export type HandoffState = "embedded" | "releasing" | "daemon";
3
+ import type { BackendKind, PeerHost } from "./peer-host.js";
4
+ export type HandoffState = "embedded" | "releasing" | "daemon" | "waiting";
5
+ /** How long a vanished daemon is treated as "restarting" before auto mode
6
+ * gives up on it and takes the identity back. `agentnet restart` and a
7
+ * launchd/systemd kickstart are both back well inside this. */
8
+ export declare const DAEMON_GRACE_MS: number;
5
9
  export interface SwitchableHostOptions {
6
10
  dataDir: string;
7
11
  /** Build a fresh embedded host. Called on first start and on every fallback,
@@ -11,6 +15,14 @@ export interface SwitchableHostOptions {
11
15
  makeDaemon: () => PeerHost;
12
16
  onChange?: (state: HandoffState, host: PeerHost | null) => void;
13
17
  log?: Logger;
18
+ /** The backend the user pinned with `--backend`, if any. A forced backend is
19
+ * never switched away from automatically — in EITHER direction. Auto mode
20
+ * (undefined) keeps the full state machine. */
21
+ force?: BackendKind;
22
+ /** Grace period before auto mode falls back from a vanished daemon. */
23
+ graceMs?: number;
24
+ /** Liveness probe, injectable for tests. Defaults to a real connect. */
25
+ probe?: (dataDir: string) => Promise<boolean>;
14
26
  }
15
27
  /**
16
28
  * A PeerHost whose backend can change under it.
@@ -13,12 +13,16 @@
13
13
  // `releasing` is the honest name for the middle state: our peer is down and the
14
14
  // daemon is not up yet, so messaging is offline and the UI must say so. Hiding
15
15
  // that gap would make dropped messages look like a network fault.
16
- import { daemonIsRunning } from "./ipc.js";
16
+ import { probeDaemon } from "./ipc.js";
17
17
  import { Logger } from "./logger.js";
18
18
  /** How often we look for the daemon socket appearing or vanishing. */
19
19
  const WATCH_INTERVAL_MS = 1500;
20
20
  /** How long we stay in `releasing` before giving up and coming back. */
21
21
  const RELEASE_TIMEOUT_MS = 10 * 60 * 1000;
22
+ /** How long a vanished daemon is treated as "restarting" before auto mode
23
+ * gives up on it and takes the identity back. `agentnet restart` and a
24
+ * launchd/systemd kickstart are both back well inside this. */
25
+ export const DAEMON_GRACE_MS = 60 * 1000;
22
26
  /**
23
27
  * A PeerHost whose backend can change under it.
24
28
  *
@@ -34,11 +38,16 @@ export class SwitchablePeerHost {
34
38
  #timer = null;
35
39
  #releaseDeadline = 0;
36
40
  #busy = false;
41
+ /** When the daemon was first seen missing, 0 while it is present. Drives the
42
+ * grace period; reset the moment it answers again. */
43
+ #daemonMissingSince = 0;
44
+ #probe;
37
45
  constructor(initial, opts) {
38
46
  this.#inner = initial;
39
47
  this.#state = initial.kind === "daemon" ? "daemon" : "embedded";
40
48
  this.#opts = opts;
41
49
  this.#log = opts.log ?? new Logger({ prefix: "LAN" });
50
+ this.#probe = opts.probe ?? ((dir) => probeDaemon(dir));
42
51
  this.#watch();
43
52
  }
44
53
  get kind() {
@@ -62,7 +71,9 @@ export class SwitchablePeerHost {
62
71
  // freezing, which reads as a bug rather than as "you have a step to do".
63
72
  return {
64
73
  ok: false,
65
- error: "Beagle released its peer and is waiting for the decentlan daemon to start. Messaging resumes when it does.",
74
+ error: this.#state === "waiting"
75
+ ? "The decentlan daemon is restarting. Beagle is holding this identity for it and will re-attach automatically."
76
+ : "Beagle released its peer and is waiting for the decentlan daemon to start. Messaging resumes when it does.",
66
77
  };
67
78
  }
68
79
  return this.#inner.call(req);
@@ -122,7 +133,39 @@ export class SwitchablePeerHost {
122
133
  async #tick() {
123
134
  if (this.#busy)
124
135
  return;
125
- const daemonUp = daemonIsRunning(this.#opts.dataDir);
136
+ const daemonUp = await this.#probe(this.#opts.dataDir);
137
+ if (daemonUp)
138
+ this.#daemonMissingSince = 0;
139
+ else if (this.#daemonMissingSince === 0)
140
+ this.#daemonMissingSince = Date.now();
141
+ // A tick can await; make sure nothing started while we were probing.
142
+ if (this.#busy)
143
+ return;
144
+ // `--backend daemon` means "this box's identity belongs to the daemon".
145
+ // Honour that at RUNTIME too, not just at startup. Falling back here is
146
+ // what deadlocked 245 twice: the embedded peer took <dataDir>/daemon.pid,
147
+ // launchd then could not restart the daemon (it refuses when a live
148
+ // process holds the identity), and the "way back" could never fire because
149
+ // it waits for a daemon that our own lock is keeping down. `waiting` is
150
+ // the honest state — no peer, no lock, ops rejected with a reason — and it
151
+ // costs a minute of messaging instead of needing a human to kill beagle
152
+ // and rm the pidfile.
153
+ if (this.#opts.force === "daemon") {
154
+ if (this.#state === "daemon" && !daemonUp) {
155
+ this.#log.info("the daemon went away — waiting for it to come back (forced daemon backend)");
156
+ const old = this.#inner;
157
+ this.#inner = null;
158
+ this.#setState("waiting");
159
+ await old?.stop();
160
+ return;
161
+ }
162
+ if (this.#state === "waiting" && daemonUp) {
163
+ this.#log.info("the daemon is back — re-attaching");
164
+ this.#inner = this.#opts.makeDaemon();
165
+ this.#setState("daemon");
166
+ }
167
+ return;
168
+ }
126
169
  if (this.#state === "releasing") {
127
170
  if (daemonUp) {
128
171
  this.#busy = true;
@@ -143,7 +186,18 @@ export class SwitchablePeerHost {
143
186
  // The daemon went away (stopped, uninstalled, crashed). Take the identity
144
187
  // back so messaging keeps working — a user who stops the LAN service should
145
188
  // not silently lose chat.
189
+ //
190
+ // But WAIT first. A daemon that was here a second ago is far more often
191
+ // restarting than gone: `agentnet restart` is the documented upgrade step,
192
+ // and a supervisor kickstart looks identical. Grabbing the identity lock in
193
+ // that gap is what stops the daemon coming back at all, so the fast path
194
+ // was the deadlock. One minute of the daemon's own downtime is cheap; the
195
+ // deadlock cost 20 minutes and a human.
146
196
  if (this.#state === "daemon" && !daemonUp) {
197
+ const downMs = Date.now() - this.#daemonMissingSince;
198
+ const graceMs = this.#opts.graceMs ?? DAEMON_GRACE_MS;
199
+ if (downMs < graceMs)
200
+ return; // probably a restart — say nothing, just wait
147
201
  await this.#backToEmbedded("the daemon is no longer running");
148
202
  return;
149
203
  }
@@ -155,7 +209,12 @@ export class SwitchablePeerHost {
155
209
  // was running the whole time, invisible to it. The version panel then
156
210
  // reported the APP's peer as the daemon's, so an update that had actually
157
211
  // landed looked like it had not.
158
- if (this.#state === "embedded" && daemonUp) {
212
+ //
213
+ // Not when the user pinned `--backend embedded`, though. That flag used to
214
+ // stop at startup: cli.ts wrapped the forced host in this watcher, which
215
+ // promoted it right back. Beagle Desktop passes `--backend embedded` and
216
+ // was demoted within 1.5s anyway — on Windows to a pipe nobody served.
217
+ if (this.#state === "embedded" && daemonUp && this.#opts.force !== "embedded") {
159
218
  await this.#backToDaemon();
160
219
  }
161
220
  }
@@ -189,6 +248,19 @@ export class SwitchablePeerHost {
189
248
  return;
190
249
  this.#busy = true;
191
250
  try {
251
+ // Last look before we commit. makeEmbedded() takes <dataDir>/daemon.pid,
252
+ // and that write is the point of no return: from then on the daemon
253
+ // cannot start, so it cannot prove us wrong. Rather than thread a probe
254
+ // into acquireIdentityLock — which would have to be sync, and a sync
255
+ // check is exactly what could not answer on Windows — close the window
256
+ // here, where the decision is actually made and awaiting is free.
257
+ if (await this.#probe(this.#opts.dataDir)) {
258
+ this.#log.info("the daemon answered after all — staying off the identity lock");
259
+ this.#daemonMissingSince = 0;
260
+ this.#inner = this.#opts.makeDaemon();
261
+ this.#setState("daemon");
262
+ return;
263
+ }
192
264
  this.#log.info(`falling back to the embedded peer: ${why}`);
193
265
  this.#inner = null;
194
266
  this.#inner = await this.#opts.makeEmbedded();
package/dist/peer-host.js CHANGED
@@ -21,7 +21,7 @@ import { homedir } from "node:os";
21
21
  import { resolve } from "node:path";
22
22
  import { existsSync, readFileSync } from "node:fs";
23
23
  import yaml from "js-yaml";
24
- import { daemonIsRunning, ipcCall, ipcSocketPath } from "./ipc.js";
24
+ import { ipcCall, ipcSocketPath, probeDaemon } from "./ipc.js";
25
25
  /** Where decentlan keeps its Carrier identity and daemon socket. Beagle reuses
26
26
  * this identity when it exists so a user's friends carry over — the whole
27
27
  * point of the split is that it is the same node, not a new one. */
@@ -73,7 +73,7 @@ export function makeDaemonHost(dataDir) {
73
73
  */
74
74
  export async function openPeerHost(opts) {
75
75
  const dataDir = decentlanCarrierDir(opts.configDir);
76
- if (opts.force !== "embedded" && daemonIsRunning(dataDir)) {
76
+ if (opts.force !== "embedded" && (await probeDaemon(dataDir))) {
77
77
  return { host: new DaemonHost(dataDir), why: `decentlan daemon at ${dataDir}` };
78
78
  }
79
79
  if (opts.force === "daemon") {
@@ -91,7 +91,7 @@ export async function openPeerHost(opts) {
91
91
  const waitMs = Math.max(0, opts.waitForDaemonMs ?? 60_000);
92
92
  const deadline = Date.now() + waitMs;
93
93
  let waited = false;
94
- while (!daemonIsRunning(dataDir)) {
94
+ while (!(await probeDaemon(dataDir))) {
95
95
  if (Date.now() >= deadline) {
96
96
  throw new Error(`--backend daemon was requested but no decentlan daemon appeared within ${Math.round(waitMs / 1000)}s ` +
97
97
  `(looked for ${ipcSocketPath(dataDir)}).`);
package/dist/server.js CHANGED
@@ -1975,6 +1975,11 @@ export function startBeagleServer(opts) {
1975
1975
  // be ordered, which is why the sidebar never reordered on new
1976
1976
  // activity.
1977
1977
  lastTs: lm?.ts ?? 0,
1978
+ // When the friendship was established. The sidebar sorts by last
1979
+ // message and falls back to this, so a friend you just accepted —
1980
+ // who by definition has no messages — lands at the top of the
1981
+ // no-history group instead of alphabetically halfway down it.
1982
+ acceptedAt: f.lastSeen ?? 0,
1978
1983
  wire: "163",
1979
1984
  avatarUrl: ensPub.get(uid)?.avatarUrl ?? null,
1980
1985
  // What they advertised over Carrier wins over the directory: it is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.87",
3
+ "version": "0.1.88",
4
4
  "description": "Beagle — P2P chat, file transfer and calls for regular users, on the Decent Network. No admin privilege required.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -21,6 +21,7 @@
21
21
  "scripts": {
22
22
  "build": "npm run build -w @decentnetwork/beagle-ui && tsc -p tsconfig.json && chmod +x dist/cli.js && node scripts/build-ui.mjs",
23
23
  "typecheck": "tsc --noEmit",
24
+ "test": "tsc -p tsconfig.test.json && node --test .test-build/test/*.test.js",
24
25
  "clean": "rm -rf dist",
25
26
  "start": "node dist/cli.js"
26
27
  },