ccdeck 3.29.11 → 3.31.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/README.md +4 -3
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-n63GjLhv.js +1 -0
- package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
- package/dist/web/assets/index-CZJf1Cpp.js +299 -0
- package/dist/web/assets/index-D4q1ElXU.css +1 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +40 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +7 -3
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +2 -2
- package/src/server/deck-prefs.mjs +58 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +174 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +110 -0
- package/src/server/prefs-give-back.mjs +206 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +86 -0
- package/src/server/prefs-routes.mjs +99 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +70 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/provider-status.mjs +307 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +108 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
- package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
- package/dist/web/assets/index-jmrcEsCj.js +0 -298
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Following an append-only JSONL one bounded chunk at a time: the read that
|
|
2
|
+
// hands back only complete lines and moves a cursor past exactly the bytes they
|
|
3
|
+
// occupy, and the ceiling on what any one read may allocate.
|
|
4
|
+
//
|
|
5
|
+
// These lived in src/server/transcript-scan.mjs, which still reads every Claude
|
|
6
|
+
// transcript through them, and the Codex side borrowed them from there: the
|
|
7
|
+
// rollout watcher tails rollouts with readAppendedLines, and
|
|
8
|
+
// codex-enrichment.mjs reads a rollout's head and tail with readByteRange. A
|
|
9
|
+
// reader that knows nothing about what either kind of file says is a module of
|
|
10
|
+
// its own. The bodies are unchanged, and index.mjs still re-exports
|
|
11
|
+
// MAX_SCAN_CHUNK and readAppendedLines.
|
|
12
|
+
import { open } from "node:fs/promises";
|
|
13
|
+
|
|
14
|
+
// ─── Following an append-only JSONL, one bounded chunk at a time ─────────
|
|
15
|
+
// The most bytes ONE read of a transcript may allocate, and the ceiling that
|
|
16
|
+
// makes the size of the file irrelevant to the size of the allocation.
|
|
17
|
+
//
|
|
18
|
+
// #674 is what its absence cost. `readByteRange` was called with `to` set to
|
|
19
|
+
// `stat().size` on a path that arrives, unvalidated, in the body of a
|
|
20
|
+
// credential-free `POST /api/event`, and it answered by allocating the whole of
|
|
21
|
+
// it — once as a zero-filled Buffer and again as the string `toString` builds
|
|
22
|
+
// from it. Measured here on Node 22.14 / macOS against a real deck on loopback,
|
|
23
|
+
// sampling `process.memoryUsage.rss()` every 5 ms:
|
|
24
|
+
//
|
|
25
|
+
// 400 MB file, one POST: 51MB -> 848MB (2x: the Buffer and the string)
|
|
26
|
+
// 700 MB file, one POST: 52MB -> 753MB (1x: 700 MB is past V8's ~512 MB
|
|
27
|
+
// max string, so `toString` threw
|
|
28
|
+
// into scanTranscript's catch and
|
|
29
|
+
// only the Buffer was paid for)
|
|
30
|
+
//
|
|
31
|
+
// Both answered 200. Neither is a one-off: see `readAppendedLines` for the
|
|
32
|
+
// second half of that defect, the cursor that could not advance.
|
|
33
|
+
//
|
|
34
|
+
// 8 MiB is picked against the only measurement that bears on it — the longest
|
|
35
|
+
// single line in a real transcript. The note above `maybeResolveSessionName`
|
|
36
|
+
// in session-enrichment.mjs measures that at 710 KB in the 46.4 MB session on
|
|
37
|
+
// this machine, one big tool result on one line, so the ceiling is about eleven
|
|
38
|
+
// times the worst line seen and no real transcript line is ever split by it. Its worst
|
|
39
|
+
// case is 8 MiB of Buffer plus up to 8 MiB of string per in-flight scan, which
|
|
40
|
+
// is an eighth of the 128 MiB the ring buffer is already allowed to hold.
|
|
41
|
+
export const MAX_SCAN_CHUNK = 8 * 1024 * 1024;
|
|
42
|
+
|
|
43
|
+
const NEWLINE = 0x0a;
|
|
44
|
+
|
|
45
|
+
/** Up to MAX_SCAN_CHUNK bytes of `path` from `from`, as a Buffer of exactly the
|
|
46
|
+
* bytes that were read. The cap is here rather than at the call sites so that
|
|
47
|
+
* no caller — including one added later — can name a range that allocates more
|
|
48
|
+
* than the ceiling above. */
|
|
49
|
+
async function readByteChunk(path, from, to) {
|
|
50
|
+
const len = Math.min(to - from, MAX_SCAN_CHUNK);
|
|
51
|
+
if (len <= 0) return Buffer.alloc(0);
|
|
52
|
+
const fh = await open(path, "r");
|
|
53
|
+
try {
|
|
54
|
+
const buf = Buffer.alloc(len);
|
|
55
|
+
const { bytesRead } = await fh.read(buf, 0, len, from);
|
|
56
|
+
return bytesRead === len ? buf : buf.subarray(0, bytesRead);
|
|
57
|
+
} finally {
|
|
58
|
+
await fh.close();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function readByteRange(path, from, to) {
|
|
63
|
+
return (await readByteChunk(path, from, to)).toString("utf8");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The next batch of COMPLETE lines appended to a JSONL file, and the cursor
|
|
68
|
+
* moved past exactly the bytes they occupy.
|
|
69
|
+
*
|
|
70
|
+
* `cursor` is `{ offset, midLine }` and is written in place; `size` is the
|
|
71
|
+
* caller's `stat()` reading. Returns `{ text, advanced }` where `advanced` is
|
|
72
|
+
* the number of bytes the cursor moved — zero means "nothing complete to fold
|
|
73
|
+
* yet", which is the caller's signal to stop.
|
|
74
|
+
*
|
|
75
|
+
* THE THREE THINGS THIS HAS TO GET RIGHT, and the one it used to get wrong:
|
|
76
|
+
*
|
|
77
|
+
* 1. A partial last line is not an error. A transcript is being appended to
|
|
78
|
+
* while it is read, so the tail of any chunk that ends at EOF is routinely
|
|
79
|
+
* half a line. Those bytes stay unread and the cursor does not move —
|
|
80
|
+
* the next pass sees the whole line once its newline lands.
|
|
81
|
+
*
|
|
82
|
+
* 2. A chunk with no newline in it that DOES NOT end at EOF is a different
|
|
83
|
+
* thing entirely, and the old code could not tell the two apart: it
|
|
84
|
+
* returned without advancing in both cases. For any file with no newline in
|
|
85
|
+
* it — any binary, a sparse file a caller makes for the purpose — that made
|
|
86
|
+
* the early return permanent, so every later POST re-read the whole file
|
|
87
|
+
* from byte 0. Measured before the fix, the same 400 MB file: 797 MB on the
|
|
88
|
+
* first post, 400 MB on the second, 400 MB on the third. Here that case is
|
|
89
|
+
* what `midLine` names: a full chunk with no line boundary anywhere in it
|
|
90
|
+
* is a line longer than the ceiling, there is no line there to wait for, so
|
|
91
|
+
* the cursor walks past it and the fragment that follows is dropped when
|
|
92
|
+
* the next boundary arrives.
|
|
93
|
+
*
|
|
94
|
+
* 3. The cursor is moved by BYTES, taken from the buffer, never by
|
|
95
|
+
* `Buffer.byteLength` of the decoded text. Chunking can split a multi-byte
|
|
96
|
+
* character at the ceiling, and `toString` turns a split character into a
|
|
97
|
+
* replacement character of a different width — so measuring the advance on
|
|
98
|
+
* the string would drift the cursor on any transcript containing non-ASCII,
|
|
99
|
+
* which is most of them. Slicing at the last newline (a byte that cannot be
|
|
100
|
+
* part of a multi-byte sequence) and advancing by its index is exact.
|
|
101
|
+
*/
|
|
102
|
+
export async function readAppendedLines(path, cursor, size, chunkMax = MAX_SCAN_CHUNK) {
|
|
103
|
+
const from = cursor.offset;
|
|
104
|
+
const want = Math.min(size - from, chunkMax);
|
|
105
|
+
if (want <= 0) return { text: "", advanced: 0 };
|
|
106
|
+
const buf = await readByteChunk(path, from, from + want);
|
|
107
|
+
if (buf.length === 0) return { text: "", advanced: 0 };
|
|
108
|
+
|
|
109
|
+
const lastNl = buf.lastIndexOf(NEWLINE);
|
|
110
|
+
if (lastNl < 0) {
|
|
111
|
+
// Reaching the end of the file with no newline in hand is the ordinary
|
|
112
|
+
// partial last line: wait for its newline. (1) above. The test is where the
|
|
113
|
+
// read STOPPED, not how much was asked for, because `readByteChunk` clamps
|
|
114
|
+
// to MAX_SCAN_CHUNK and a short read is normally that clamp rather than the
|
|
115
|
+
// end of anything.
|
|
116
|
+
if (from + buf.length >= size) return { text: "", advanced: 0 };
|
|
117
|
+
// A chunk with no line boundary in it, and more file after it. (2).
|
|
118
|
+
cursor.offset = from + buf.length;
|
|
119
|
+
cursor.midLine = true;
|
|
120
|
+
return { text: "", advanced: buf.length };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const advanced = lastNl + 1; // (3): bytes, up to and including the \n
|
|
124
|
+
let text = buf.toString("utf8", 0, lastNl);
|
|
125
|
+
if (cursor.midLine) {
|
|
126
|
+
// This chunk opens in the middle of a line we already walked past, so
|
|
127
|
+
// everything up to the first boundary is the tail of it and folding it
|
|
128
|
+
// would fold a fragment. Only the lines after it are whole.
|
|
129
|
+
const nl = text.indexOf("\n");
|
|
130
|
+
text = nl < 0 ? "" : text.slice(nl + 1);
|
|
131
|
+
cursor.midLine = false;
|
|
132
|
+
}
|
|
133
|
+
cursor.offset = from + advanced;
|
|
134
|
+
return { text, advanced };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// What the Codex side calls besides the two exported above. Listed rather than
|
|
138
|
+
// marked, so the declaration reads as it did where it came from.
|
|
139
|
+
export { readByteRange };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// This deck's accounts in the shape the sync rules want, read off the rows
|
|
2
|
+
// claude-swap's reader hands back. Lifted out of createEngine in lan-engine.mjs,
|
|
3
|
+
// where it closed over nothing but the reader: the rule for each field is here
|
|
4
|
+
// and runs without a store, and the engine reads the store and hands the rows
|
|
5
|
+
// over. What each verdict means is account-health.mjs's.
|
|
6
|
+
import { accountKey, SENDER_UNREADABLE } from "./lan-sync.mjs";
|
|
7
|
+
import { storedCopyAlive, cachedExportReadable } from "./account-health.mjs";
|
|
8
|
+
|
|
9
|
+
/** Every account in `got` — what the deck's account reader returns, or
|
|
10
|
+
* nothing — as the rules in lan-copies.mjs read one. */
|
|
11
|
+
export function syncAccounts(got) {
|
|
12
|
+
return (got?.accounts ?? []).map(a => ({
|
|
13
|
+
key: accountKey(a.email, a.orgUuid),
|
|
14
|
+
email: a.email,
|
|
15
|
+
org: a.orgUuid,
|
|
16
|
+
alive: storedCopyAlive(a.alive, a.collector),
|
|
17
|
+
// False only for a login the wiring knows this process cannot read (a
|
|
18
|
+
// Mac whose Keychain will not open from here). Absent means readable,
|
|
19
|
+
// which is every deck that does not say.
|
|
20
|
+
readable: a.readable !== false && cachedExportReadable(a.collector, { active: a.active === true }),
|
|
21
|
+
unreadableWhy: a.readable === false || a.collector === "keychain_unavailable"
|
|
22
|
+
? SENDER_UNREADABLE : "export failed",
|
|
23
|
+
num: a.num,
|
|
24
|
+
// The one this deck is on — claude-swap's own answer, one at most.
|
|
25
|
+
active: a.active === true,
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
// The shouting half of the sockets: the UDP beacon that tells the network this
|
|
2
|
+
// deck is here, and hears every other deck that says the same. lan-socket.mjs
|
|
3
|
+
// is the other half — the listener that answers and the dialler that calls —
|
|
4
|
+
// and, as there, every decision this makes lives in lan-sync.mjs.
|
|
5
|
+
//
|
|
6
|
+
// A DECK HEARS ITSELF. Measured, not assumed: a broadcast to 255.255.255.255
|
|
7
|
+
// comes back to every socket on the sending machine bound to that port, from
|
|
8
|
+
// the machine's own LAN address rather than from loopback. That is what makes
|
|
9
|
+
// two decks on one machine find each other — which is how this gets tested at
|
|
10
|
+
// all — and it is why self-recognition is by fingerprint rather than by
|
|
11
|
+
// address.
|
|
12
|
+
import dgram from "node:dgram";
|
|
13
|
+
import { randomBytes } from "node:crypto";
|
|
14
|
+
import { networkInterfaces } from "node:os";
|
|
15
|
+
import { looksLikeTunnel } from "./route-via.mjs";
|
|
16
|
+
import {
|
|
17
|
+
beaconPayload, beaconVerdict, hostId, notePeer, readBeacon,
|
|
18
|
+
ANNOUNCE_MS, MAX_BEACON_BYTES,
|
|
19
|
+
} from "./lan-sync.mjs";
|
|
20
|
+
|
|
21
|
+
/** An IPv4 dotted quad as four numbers, or null for anything that is not one. */
|
|
22
|
+
function quad(text) {
|
|
23
|
+
const parts = String(text ?? "").split(".");
|
|
24
|
+
if (parts.length !== 4) return null;
|
|
25
|
+
const out = parts.map(p => Number(p));
|
|
26
|
+
return out.every(n => Number.isInteger(n) && n >= 0 && n <= 255) ? out : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The address that reaches every host on one interface's own subnet.
|
|
31
|
+
*
|
|
32
|
+
* `address | ~netmask`, which is the definition. Null for a /32, because a
|
|
33
|
+
* point-to-point link — a VPN tunnel, `utun` on macOS — has a directed
|
|
34
|
+
* broadcast equal to its own address, and sending a beacon to ourselves down a
|
|
35
|
+
* tunnel is a packet nobody wanted.
|
|
36
|
+
*/
|
|
37
|
+
export function directedBroadcast(address, netmask) {
|
|
38
|
+
const a = quad(address);
|
|
39
|
+
const m = quad(netmask);
|
|
40
|
+
if (!a || !m) return null;
|
|
41
|
+
if (m.every(o => o === 255)) return null;
|
|
42
|
+
return a.map((o, i) => o | (~m[i] & 255)).join(".");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Where a beacon has to go to be heard on this machine's networks.
|
|
47
|
+
*
|
|
48
|
+
* The limited broadcast first, because it is the one that works where a router
|
|
49
|
+
* or an access point filters the directed form, and because it is what every
|
|
50
|
+
* deck before this version sent — a machine that was fine stays fine. Then one
|
|
51
|
+
* per interface, which is what a multi-homed host actually needs: see announce.
|
|
52
|
+
*
|
|
53
|
+
* Loopback and IPv6 are skipped. A deck on `lo0` can only hear itself, and this
|
|
54
|
+
* protocol is IPv4 broadcast by construction — there is no such thing as an
|
|
55
|
+
* IPv6 broadcast address.
|
|
56
|
+
*/
|
|
57
|
+
export function broadcastTargets(ifaces) {
|
|
58
|
+
return broadcastPlan(ifaces).map(p => p.to);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The same targets, each with the interface whose subnet it is — null for the
|
|
63
|
+
* limited broadcast, which is nobody's. What lets a beacon be held back when
|
|
64
|
+
* the machine would send it out by some OTHER interface: see leavesByTunnel.
|
|
65
|
+
*/
|
|
66
|
+
export function broadcastPlan(ifaces) {
|
|
67
|
+
const out = [{ to: "255.255.255.255", iface: null }];
|
|
68
|
+
for (const [name, list] of Object.entries(ifaces ?? {})) {
|
|
69
|
+
for (const ni of list ?? []) {
|
|
70
|
+
if (!ni || ni.internal) continue;
|
|
71
|
+
// Node 18 reports `family` as the string "IPv4"; older shapes used 4.
|
|
72
|
+
if (ni.family !== "IPv4" && ni.family !== 4) continue;
|
|
73
|
+
const to = directedBroadcast(ni.address, ni.netmask);
|
|
74
|
+
if (to && !out.some(p => p.to === to)) out.push({ to, iface: name });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Would this broadcast leave the local network?
|
|
82
|
+
*
|
|
83
|
+
* A directed broadcast belongs to the interface whose subnet it is, so the
|
|
84
|
+
* machine sending it out by any other one means something rerouted the local
|
|
85
|
+
* network — a VPN, or a Tailscale exit node without local network access —
|
|
86
|
+
* and the beacon would come out on the far end of the tunnel. The limited
|
|
87
|
+
* broadcast belongs to none, so it is held back only when it would go into
|
|
88
|
+
* something that is plainly a tunnel. Unknown answers nothing: send.
|
|
89
|
+
*/
|
|
90
|
+
export function leavesByTunnel(target, via, isTunnel) {
|
|
91
|
+
if (!via) return false;
|
|
92
|
+
if (target.iface) return via !== target.iface;
|
|
93
|
+
return isTunnel(via);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The one fixed port in the feature. Out of the deck's HTTP range (4317-4400)
|
|
97
|
+
* so a beacon can never be mistaken for a deck's own traffic, and unassigned:
|
|
98
|
+
* 45317 reads as "4317, elsewhere", which is what a person tracing this in a
|
|
99
|
+
* firewall log needs it to say. */
|
|
100
|
+
export const DISCOVERY_PORT = 45_317;
|
|
101
|
+
|
|
102
|
+
/** The shortest gap between two "I am here too" replies to a stranger. Long
|
|
103
|
+
* enough that a burst of decks starting together cannot make a storm, short
|
|
104
|
+
* enough that starting two decks by hand feels instant. */
|
|
105
|
+
const REPLY_COOLDOWN_MS = 2_000;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The shouting half.
|
|
109
|
+
*
|
|
110
|
+
* `reuseAddr` is not a convenience: without it a second deck on the same
|
|
111
|
+
* machine cannot bind the discovery port at all, and two decks on one machine
|
|
112
|
+
* is both a real setup and the only way this gets tested here.
|
|
113
|
+
*
|
|
114
|
+
* Errors are reported, never thrown. A machine with no route, a firewall that
|
|
115
|
+
* refuses the bind, an interface that comes and goes with a VPN — none of them
|
|
116
|
+
* is a reason for the deck to fall over, and all of them are reasons for the
|
|
117
|
+
* panel to be able to say what happened.
|
|
118
|
+
*/
|
|
119
|
+
export function createBeacon({
|
|
120
|
+
port, name, fp, onPeer, onStranger, onError, onIdClash, now = Date.now,
|
|
121
|
+
/** The decks somebody has accepted, read fresh each packet so an accept takes
|
|
122
|
+
* effect immediately rather than at the next restart. */
|
|
123
|
+
trusted = () => [],
|
|
124
|
+
// Injected so the suite can drive this with a socket it controls. CI runners
|
|
125
|
+
// are not a network: GitHub's have no broadcast domain worth the name, and a
|
|
126
|
+
// test that quietly skipped there would be a test that stopped testing
|
|
127
|
+
// without saying so. The REAL socket is exercised by hand, two decks on one
|
|
128
|
+
// machine, which works because a broadcast comes back to its own host.
|
|
129
|
+
createSocket = opts => dgram.createSocket(opts),
|
|
130
|
+
// Injected for the same reason, and read PER ANNOUNCE rather than once: a
|
|
131
|
+
// laptop that joins a network, or brings a VPN up, grows an interface without
|
|
132
|
+
// restarting the deck, and a list captured at start would announce to the
|
|
133
|
+
// addresses it had at breakfast.
|
|
134
|
+
ifaces = () => networkInterfaces(),
|
|
135
|
+
/**
|
|
136
|
+
* Addresses to send the beacon to one by one, beside the broadcast — the
|
|
137
|
+
* owner's machines on Tailscale, whose tunnel carries no broadcast at all.
|
|
138
|
+
* Read per announce like `ifaces`, because the tailnet list is re-read while
|
|
139
|
+
* the deck runs and a machine that just came online belongs in the next one.
|
|
140
|
+
*/
|
|
141
|
+
unicast = () => [],
|
|
142
|
+
/**
|
|
143
|
+
* Which way a packet from this address came: "lan", "tailscale", or null for
|
|
144
|
+
* one to ignore entirely. Null is what a tailnet packet gets while this
|
|
145
|
+
* deck's Tailscale switch is off — the sockets hear it either way, because
|
|
146
|
+
* they bind every interface, and off has to mean the deck does not act on it.
|
|
147
|
+
*/
|
|
148
|
+
routeFor = () => "lan",
|
|
149
|
+
/** How long to wait before trying the discovery port again while another
|
|
150
|
+
* program holds it, and who to tell when hearing stops or comes back. */
|
|
151
|
+
rebindMs = 30_000,
|
|
152
|
+
onHearing,
|
|
153
|
+
/**
|
|
154
|
+
* Where the machine would send each broadcast — see createRouteCheck in
|
|
155
|
+
* route-via.mjs. Absent, every broadcast goes, as before; the suite's
|
|
156
|
+
* beacons have none.
|
|
157
|
+
*/
|
|
158
|
+
routes = null,
|
|
159
|
+
} = {}) {
|
|
160
|
+
// Randomised per process. Two beacons from one fingerprint with different
|
|
161
|
+
// instance ids mean the deck restarted between them, which is the signal to
|
|
162
|
+
// drop any session held for it rather than resume into a process that is gone.
|
|
163
|
+
const instance = randomBytes(8).toString("hex");
|
|
164
|
+
/** Which computer this is, as opposed to which process or which key. Derived
|
|
165
|
+
* once per beacon rather than per packet: it cannot change while a process
|
|
166
|
+
* is running, and hashing a hostname thirty seconds apart forever is work
|
|
167
|
+
* nobody asked for. See hostId. */
|
|
168
|
+
const host = hostId();
|
|
169
|
+
const peers = new Map();
|
|
170
|
+
let sock = null;
|
|
171
|
+
/**
|
|
172
|
+
* The socket beacons LEAVE by, on a port of its own. See start.
|
|
173
|
+
*
|
|
174
|
+
* Null until it is bound, and whenever it could not be — the listening
|
|
175
|
+
* socket then sends as it always did, because a beacon from the wrong port is
|
|
176
|
+
* still a beacon and none at all is a deck nobody finds.
|
|
177
|
+
*/
|
|
178
|
+
let out = null;
|
|
179
|
+
let timer = null;
|
|
180
|
+
/**
|
|
181
|
+
* Whether this deck can HEAR — whether it holds the discovery port.
|
|
182
|
+
*
|
|
183
|
+
* SEPARATE FROM RUNNING, because a deck that cannot bind 45317 is still most
|
|
184
|
+
* of a deck. It announces from its own socket, so every other deck still
|
|
185
|
+
* finds it, asks it and dials it; what it has lost is hearing them announce.
|
|
186
|
+
* Stopping the whole feature over that — which is what this did — took a
|
|
187
|
+
* working sync away from somebody whose Tailscale exit node happened to be
|
|
188
|
+
* holding the port. It keeps trying for the port instead, and takes it back
|
|
189
|
+
* the moment it is free.
|
|
190
|
+
*/
|
|
191
|
+
let hearing = false;
|
|
192
|
+
let deafError = null;
|
|
193
|
+
/** Whether the last announce held back every broadcast because the local
|
|
194
|
+
* network goes through a tunnel here — see leavesByTunnel. */
|
|
195
|
+
let tunneled = false;
|
|
196
|
+
let told = null;
|
|
197
|
+
let rebind = null;
|
|
198
|
+
let stopped = true;
|
|
199
|
+
// Socket binds finish asynchronously. A stopped start must not attach its
|
|
200
|
+
// late socket to a subsequent start or leave an outbound socket open.
|
|
201
|
+
let startGeneration = 0;
|
|
202
|
+
/** When this deck last answered a deck it had not heard, so answering cannot
|
|
203
|
+
* become a storm, and which decks it has already answered — without the
|
|
204
|
+
* second, a deck that is never accepted is answered again on every packet
|
|
205
|
+
* for as long as both are running. */
|
|
206
|
+
let repliedAt = 0;
|
|
207
|
+
const answered = new Set();
|
|
208
|
+
|
|
209
|
+
const payload = () => Buffer.from(JSON.stringify(beaconPayload({ name, fp, port, instance, host })));
|
|
210
|
+
|
|
211
|
+
const announce = (also = []) => {
|
|
212
|
+
if (!sock && !out) return;
|
|
213
|
+
const announcedIn = startGeneration;
|
|
214
|
+
// EVERY BROADCAST ADDRESS THIS MACHINE HAS, not one.
|
|
215
|
+
//
|
|
216
|
+
// This used to send only to 255.255.255.255, on the argument that the
|
|
217
|
+
// limited broadcast "needs nothing" while a subnet-directed one needs the
|
|
218
|
+
// netmask of whichever interface the packet leaves by. The argument is
|
|
219
|
+
// sound and the machine disagreed with it. Measured on a Mac with Wi-Fi and
|
|
220
|
+
// a VPN tunnel up, from a bare node process with no deck involved:
|
|
221
|
+
//
|
|
222
|
+
// send to 255.255.255.255 -> EHOSTUNREACH
|
|
223
|
+
// send to 192.168.1.255 -> sent ok
|
|
224
|
+
//
|
|
225
|
+
// On a multi-homed host the limited broadcast has no single interface to
|
|
226
|
+
// leave by, and macOS refuses it rather than choosing. The deck went on
|
|
227
|
+
// announcing into nothing for as long as that machine was up: it could still
|
|
228
|
+
// HEAR colleagues, because receiving is per-port and not per-address, so the
|
|
229
|
+
// symptom was one-sided and looked like everybody else's problem.
|
|
230
|
+
//
|
|
231
|
+
// So the limited form stays — it is the one that works where a directed
|
|
232
|
+
// broadcast is filtered, and it is what Syncthing sends — and every
|
|
233
|
+
// interface's own directed broadcast goes out beside it. A duplicate packet
|
|
234
|
+
// costs one datagram; a missing one costs the whole feature.
|
|
235
|
+
const plan = broadcastPlan(ifaces());
|
|
236
|
+
// Asked behind the send, and read from what was last answered: the first
|
|
237
|
+
// beacon after a start goes as it always did, and the ones after it know.
|
|
238
|
+
void routes?.want?.(plan.map(p => p.to));
|
|
239
|
+
const kept = routes ? plan.filter(p => !leavesByTunnel(p, routes.via(p.to), looksLikeTunnel)) : plan;
|
|
240
|
+
tunneled = kept.length === 0;
|
|
241
|
+
const targets = kept.map(p => p.to);
|
|
242
|
+
const from = out ?? sock;
|
|
243
|
+
let left = targets.length;
|
|
244
|
+
const failed = [];
|
|
245
|
+
for (const to of targets) {
|
|
246
|
+
from.send(payload(), DISCOVERY_PORT, to, err => {
|
|
247
|
+
// A send may finish after this socket has closed and a new LAN
|
|
248
|
+
// session has started. Its result says nothing about the new session.
|
|
249
|
+
if (stopped || announcedIn !== startGeneration) return;
|
|
250
|
+
if (err) failed.push(`${to} (${err.code ?? err.message})`);
|
|
251
|
+
// REPORTED ONLY WHEN EVERY ONE FAILED. One address being unreachable is
|
|
252
|
+
// the ordinary state of a machine with a VPN up, and a panel that said
|
|
253
|
+
// so every thirty seconds would be crying wolf about a working deck.
|
|
254
|
+
// No address working at all is a deck nobody can discover, which is
|
|
255
|
+
// exactly what the panel is for.
|
|
256
|
+
if (--left === 0 && failed.length === targets.length) {
|
|
257
|
+
onError?.("announce", new Error(`no broadcast address worked — ${failed.join(", ")}`));
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
// AND ONE PACKET PER TAILNET MACHINE. Not part of the verdict above: a node
|
|
262
|
+
// that has gone to sleep since the list was read is the ordinary state of a
|
|
263
|
+
// laptop, and it says nothing about whether this deck can be discovered.
|
|
264
|
+
let direct = [];
|
|
265
|
+
try { direct = [...(unicast() ?? []), ...also]; } catch { direct = [...also]; }
|
|
266
|
+
for (const to of new Set(direct)) {
|
|
267
|
+
if (typeof to !== "string" || !to || targets.includes(to)) continue;
|
|
268
|
+
from.send(payload(), DISCOVERY_PORT, to, () => {});
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const onMessage = (msg, rinfo) => {
|
|
273
|
+
// A queued UDP callback can run after close(). It belongs to the stopped
|
|
274
|
+
// discovery session and must not repopulate peers or invite strangers.
|
|
275
|
+
if (stopped) return;
|
|
276
|
+
// Everything about whether to care lives in lan-sync.mjs. This hands it
|
|
277
|
+
// the bytes and the address and does what it is told.
|
|
278
|
+
if (msg.length > MAX_BEACON_BYTES) return;
|
|
279
|
+
let via = "lan";
|
|
280
|
+
try { via = routeFor(rinfo.address); } catch { via = "lan"; }
|
|
281
|
+
if (!via) return;
|
|
282
|
+
const beacon = readBeacon(msg);
|
|
283
|
+
const verdict = beaconVerdict(beacon, { selfFp: fp, selfInstance: instance, selfHost: host, trusted: trusted() });
|
|
284
|
+
// ANSWER A DECK WE HAVE NEVER HEARD, once, WHOEVER IT IS — and that last
|
|
285
|
+
// part is the change. It used to answer only a deck already in the group,
|
|
286
|
+
// which was fine when a group existed. Now the first thing a new deck has
|
|
287
|
+
// to become is a row on somebody's screen, and it cannot become one if
|
|
288
|
+
// this deck never tells it that it exists.
|
|
289
|
+
//
|
|
290
|
+
// Measured on two real decks before any of this: deck 1 saw deck 2 the
|
|
291
|
+
// instant it started and deck 2 saw nobody, because deck 1's own
|
|
292
|
+
// immediate announce went out before deck 2 was listening. Thirty seconds
|
|
293
|
+
// of an empty list is how a working feature reads as broken.
|
|
294
|
+
//
|
|
295
|
+
// At most once every few seconds, because the obvious version is a shout
|
|
296
|
+
// storm: two decks answering each other's answers forever. A new pair
|
|
297
|
+
// converges in two extra packets.
|
|
298
|
+
const newToUs = beacon && verdict !== "self" && verdict !== "id-clash" && verdict !== "unreadable"
|
|
299
|
+
&& !peers.has(beacon.fp) && !answered.has(beacon.fp);
|
|
300
|
+
if (newToUs && now() - repliedAt > REPLY_COOLDOWN_MS) {
|
|
301
|
+
repliedAt = now();
|
|
302
|
+
answered.add(beacon.fp);
|
|
303
|
+
// A deck that reached this one over the tailnet is answered there too:
|
|
304
|
+
// a broadcast never gets back down its tunnel.
|
|
305
|
+
announce(via === "tailscale" ? [rinfo.address] : []);
|
|
306
|
+
}
|
|
307
|
+
if (verdict !== "peer") {
|
|
308
|
+
// Another deck is using this one's key — see beaconVerdict. Reported
|
|
309
|
+
// rather than fixed here: this file carries packets, and choosing a new
|
|
310
|
+
// identity for the deck belongs to whoever stores it.
|
|
311
|
+
if (verdict === "id-clash") onIdClash?.();
|
|
312
|
+
// A DECK NOBODY HAS ACCEPTED. It is not refused and not silently
|
|
313
|
+
// dropped: it is a name and an address on the same network, which is a
|
|
314
|
+
// row somebody can accept. Nothing is asked of it and nothing is
|
|
315
|
+
// offered to it until they do.
|
|
316
|
+
if (verdict === "stranger") {
|
|
317
|
+
onStranger?.({
|
|
318
|
+
fp: beacon.fp, name: beacon.name, addr: rinfo.address, port: beacon.port,
|
|
319
|
+
// Carried through so the list can show one row per machine rather
|
|
320
|
+
// than one per key that machine has ever held.
|
|
321
|
+
host: beacon.host, at: now(), via,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const noted = notePeer(peers, beacon, rinfo.address, now(), via);
|
|
327
|
+
if (noted.changed || noted.restarted) onPeer?.(noted);
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
/** Tell whoever asked, once per change rather than once per try. */
|
|
331
|
+
const tell = now => {
|
|
332
|
+
if (told === now) return;
|
|
333
|
+
told = now;
|
|
334
|
+
onHearing?.(now);
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
/** One try at the discovery port: the listening socket, or the error that
|
|
338
|
+
* kept it. A bind that fails never calls back; it arrives as an error. */
|
|
339
|
+
const listen = (startedIn) => new Promise(resolve => {
|
|
340
|
+
let s;
|
|
341
|
+
try { s = createSocket({ type: "udp4", reuseAddr: true }); } catch (err) { resolve({ err }); return; }
|
|
342
|
+
let bound = false;
|
|
343
|
+
s.on("error", err => {
|
|
344
|
+
if (bound) {
|
|
345
|
+
if (!stopped && startedIn === startGeneration) onError?.("socket", err);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
try { s.close(); } catch { /* never opened */ }
|
|
349
|
+
resolve({ err });
|
|
350
|
+
});
|
|
351
|
+
// close() can leave a message callback queued. After restart, stopped is
|
|
352
|
+
// false again, so the socket's own generation must also be checked.
|
|
353
|
+
s.on("message", (msg, rinfo) => {
|
|
354
|
+
if (startedIn !== startGeneration) return;
|
|
355
|
+
onMessage(msg, rinfo);
|
|
356
|
+
});
|
|
357
|
+
s.bind(DISCOVERY_PORT, "0.0.0.0", () => {
|
|
358
|
+
bound = true;
|
|
359
|
+
try { s.setBroadcast(true); } catch (err) { onError?.("broadcast", err); }
|
|
360
|
+
resolve({ sock: s });
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const tryListen = async (startedIn) => {
|
|
365
|
+
if (stopped || startedIn !== startGeneration) return;
|
|
366
|
+
const got = await listen(startedIn);
|
|
367
|
+
if (stopped || startedIn !== startGeneration) { try { got.sock?.close(); } catch { /* gone */ } return; }
|
|
368
|
+
if (got.sock) {
|
|
369
|
+
sock = got.sock;
|
|
370
|
+
hearing = true;
|
|
371
|
+
deafError = null;
|
|
372
|
+
tell(true);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
hearing = false;
|
|
376
|
+
deafError = got.err;
|
|
377
|
+
tell(false);
|
|
378
|
+
rebind = setTimeout(() => { rebind = null; void tryListen(startedIn); }, rebindMs);
|
|
379
|
+
rebind.unref?.();
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The socket beacons leave by, bound to whatever port the OS gives it.
|
|
384
|
+
*
|
|
385
|
+
* SENT FROM A PORT OF ITS OWN, NOT FROM 45317. Nothing that hears a beacon
|
|
386
|
+
* reads the port it came from — the reply goes to 45317 whatever the source
|
|
387
|
+
* — and sending from the discovery port gave that port away. Measured on a
|
|
388
|
+
* Mac that is a Tailscale exit node: a deck elsewhere on the tailnet routed a
|
|
389
|
+
* beacon through it, and Tailscale's forwarder binds its end of every UDP
|
|
390
|
+
* flow to the CLIENT's source port (netstack.go, forwardUDP), idling it out
|
|
391
|
+
* after two minutes. A beacon every thirty seconds never idles, so the Mac's
|
|
392
|
+
* own deck could never bind 45317 again. From an ephemeral port, the
|
|
393
|
+
* forwarder takes an ephemeral port.
|
|
394
|
+
*/
|
|
395
|
+
const openOut = () => new Promise(resolve => {
|
|
396
|
+
let o;
|
|
397
|
+
try { o = createSocket({ type: "udp4" }); } catch { resolve(null); return; }
|
|
398
|
+
let opened = false;
|
|
399
|
+
o.on("error", err => {
|
|
400
|
+
if (opened) { onError?.("socket", err); return; }
|
|
401
|
+
opened = true;
|
|
402
|
+
try { o.close(); } catch { /* never opened */ }
|
|
403
|
+
resolve(null);
|
|
404
|
+
});
|
|
405
|
+
o.bind(0, "0.0.0.0", () => {
|
|
406
|
+
if (opened) return;
|
|
407
|
+
opened = true;
|
|
408
|
+
try { o.setBroadcast(true); } catch (err) { onError?.("broadcast", err); }
|
|
409
|
+
resolve(o);
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
const start = async () => {
|
|
414
|
+
const startedIn = ++startGeneration;
|
|
415
|
+
stopped = false;
|
|
416
|
+
told = null;
|
|
417
|
+
await tryListen(startedIn);
|
|
418
|
+
if (stopped || startedIn !== startGeneration) return;
|
|
419
|
+
const opened = await openOut();
|
|
420
|
+
if (stopped || startedIn !== startGeneration) {
|
|
421
|
+
try { opened?.close(); } catch { /* already closed */ }
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
out = opened;
|
|
425
|
+
// Immediately, not on the next tick. Syncthing's rule: a deck that just
|
|
426
|
+
// came up should appear now rather than up to thirty seconds later, which
|
|
427
|
+
// is the difference between "it works" and "it seems broken" for anybody
|
|
428
|
+
// who starts two decks and watches.
|
|
429
|
+
announce();
|
|
430
|
+
timer = setInterval(announce, ANNOUNCE_MS);
|
|
431
|
+
timer.unref?.();
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
start,
|
|
436
|
+
announce,
|
|
437
|
+
peers,
|
|
438
|
+
/** Whether this deck holds the discovery port, and why not when it does
|
|
439
|
+
* not. */
|
|
440
|
+
hearing: () => hearing,
|
|
441
|
+
deafError: () => deafError,
|
|
442
|
+
tunneled: () => tunneled,
|
|
443
|
+
stop() {
|
|
444
|
+
stopped = true;
|
|
445
|
+
startGeneration++;
|
|
446
|
+
if (timer) clearInterval(timer);
|
|
447
|
+
timer = null;
|
|
448
|
+
if (rebind) clearTimeout(rebind);
|
|
449
|
+
rebind = null;
|
|
450
|
+
hearing = false;
|
|
451
|
+
try { sock?.close(); } catch { /* already closed */ }
|
|
452
|
+
sock = null;
|
|
453
|
+
try { out?.close(); } catch { /* already closed */ }
|
|
454
|
+
out = null;
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
}
|