mainflow 0.29.0 → 0.30.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/cli.js +11 -7
- package/dist/{dist-BbamrIPW.js → dist-IdbnCDqM.js} +24 -4
- package/dist/facts.d.ts +1 -1
- package/dist/facts.js +1 -1
- package/dist/grant-commands.d.ts +1 -1
- package/dist/grant-commands.js +1 -1
- package/dist/hooks.d.ts +4 -4
- package/dist/hooks.js +3 -3
- package/dist/{host-BJnrv8xF.js → host-DmVNYRql.js} +135 -135
- package/dist/host.js +1 -1
- package/dist/{index-DV6Gqu8K.d.ts → index-ihgXABQc.d.ts} +1 -1
- package/dist/{launch-DefacJEL.js → launch-DHug6eli.js} +2 -2
- package/dist/launch.js +1 -1
- package/dist/native/mainflow-swap-darwin-arm64 +0 -0
- package/dist/native/mainflow-swap-darwin-x64 +0 -0
- package/dist/native/mainflow-swap-linux-arm64 +0 -0
- package/dist/native/mainflow-swap-linux-x64 +0 -0
- package/dist/{operation-Bp4U2bx3.js → operation-BQiUL0eq.js} +1103 -475
- package/dist/{ui-CiapFoik.js → ui-BqRmaHXn.js} +13 -8
- package/dist/web/assets/{index-tAb99n75.js → index-DLLPsiVq.js} +29 -29
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as AUTHORIZATION, F as renumberFiles, I as renumberPath, L as renumberText, N as applyOffline, P as renumberRequest, Q as nodeStorage, R as scopeOfPath, X as nowMinute, at as ConflictError, ct as contentHash, et as ROUTES, ot as DATA_DIR, st as WriteError, tt as requestTime, z as applyOperation } from "./host-DmVNYRql.js";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
-
import { closeSync, existsSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
|
|
4
|
-
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { accessSync, appendFileSync, chmodSync, closeSync, constants, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
7
7
|
import { parse as parse$1 } from "yaml";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
|
-
import { createServer, request } from "node:http";
|
|
10
|
-
import { request as request$1 } from "node:https";
|
|
9
|
+
import { Agent, createServer, request } from "node:http";
|
|
10
|
+
import { Agent as Agent$1, request as request$1 } from "node:https";
|
|
11
11
|
import { EventEmitter } from "node:events";
|
|
12
12
|
import { appendFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
13
13
|
import { diff3Merge } from "node-diff3";
|
|
@@ -15,12 +15,16 @@ import { diff3Merge } from "node-diff3";
|
|
|
15
15
|
/**
|
|
16
16
|
* The server could not be reached, or the connection broke. `sent` says whether the request may have reached the
|
|
17
17
|
* server: a refused connection did not, a reset or timeout after sending may have, so its result is unknown.
|
|
18
|
+
* `broken` says the connection closed under the request without an answer, as one the server closed while idle does;
|
|
19
|
+
* the server is there, so the request can go again at once under its requestId.
|
|
18
20
|
*/
|
|
19
21
|
var Unreachable = class extends Error {
|
|
20
22
|
sent;
|
|
21
|
-
|
|
23
|
+
broken;
|
|
24
|
+
constructor(message, sent, broken = false) {
|
|
22
25
|
super(message);
|
|
23
26
|
this.sent = sent;
|
|
27
|
+
this.broken = broken;
|
|
24
28
|
}
|
|
25
29
|
};
|
|
26
30
|
/** The server refused before the hub (401, 400, 404) or failed (500). */
|
|
@@ -41,21 +45,38 @@ const NOT_SENT = /* @__PURE__ */ new Set([
|
|
|
41
45
|
"UND_ERR_CONNECT_TIMEOUT",
|
|
42
46
|
"ECONNECT_TIMEOUT"
|
|
43
47
|
]);
|
|
44
|
-
/** Error codes of a pooled
|
|
48
|
+
/** Error codes of a connection that closed under a request, such as a pooled one the server had already closed. */
|
|
45
49
|
const STALE = /* @__PURE__ */ new Set([
|
|
46
50
|
"UND_ERR_SOCKET",
|
|
47
51
|
"ECONNRESET",
|
|
48
52
|
"EPIPE"
|
|
49
53
|
]);
|
|
54
|
+
/** How often the connection writes go on is used while it would be idle, so the next write finds it open. */
|
|
55
|
+
const WARM_MS = 1e4;
|
|
56
|
+
/** How long keeping the connection open waits for its answer. */
|
|
57
|
+
const WARM_TIMEOUT_MS = 5e3;
|
|
50
58
|
const causeCode = (error) => {
|
|
51
59
|
for (let cause = error; cause; cause = cause.cause) if (typeof cause.code === "string") return cause.code;
|
|
52
60
|
};
|
|
53
61
|
var ServerApi = class {
|
|
54
62
|
settings;
|
|
55
63
|
timeoutMs;
|
|
64
|
+
/**
|
|
65
|
+
* The connection writes go on, kept open between them: opening one costs two round trips, for TCP and TLS. One
|
|
66
|
+
* connection at a time, since the client sends one write at a time; a request waits for the one before it.
|
|
67
|
+
*/
|
|
68
|
+
agent;
|
|
69
|
+
/** Requests on `agent` not answered yet */
|
|
70
|
+
busy = 0;
|
|
56
71
|
constructor(settings, timeoutMs = 3e4) {
|
|
57
72
|
this.settings = settings;
|
|
58
73
|
this.timeoutMs = timeoutMs;
|
|
74
|
+
const options = {
|
|
75
|
+
keepAlive: true,
|
|
76
|
+
maxSockets: 1,
|
|
77
|
+
maxFreeSockets: 1
|
|
78
|
+
};
|
|
79
|
+
this.agent = settings.url.startsWith("https:") ? new Agent$1(options) : new Agent(options);
|
|
59
80
|
}
|
|
60
81
|
get headers() {
|
|
61
82
|
return {
|
|
@@ -63,15 +84,16 @@ var ServerApi = class {
|
|
|
63
84
|
"content-type": "application/json"
|
|
64
85
|
};
|
|
65
86
|
}
|
|
66
|
-
async call(method, route, body) {
|
|
87
|
+
async call(method, route, body, fresh = false) {
|
|
67
88
|
const url = `${this.settings.url}${route}`;
|
|
68
89
|
let answer;
|
|
69
90
|
try {
|
|
70
|
-
answer = method === "GET" ? await this.fetchGet(url) : await this.
|
|
91
|
+
answer = method === "GET" ? await this.fetchGet(url) : await this.write(method, url, JSON.stringify(body), fresh);
|
|
71
92
|
} catch (error) {
|
|
72
93
|
const code = causeCode(error);
|
|
73
94
|
const detail = [code, error.cause?.message ?? error.message].filter(Boolean).join(": ");
|
|
74
|
-
|
|
95
|
+
const sent = !(code && NOT_SENT.has(code));
|
|
96
|
+
throw new Unreachable(`cannot reach the sync server at ${this.settings.url}${detail ? ` (${detail})` : ""}`, sent, sent && STALE.has(code ?? ""));
|
|
75
97
|
}
|
|
76
98
|
let parsed;
|
|
77
99
|
try {
|
|
@@ -103,21 +125,26 @@ var ServerApi = class {
|
|
|
103
125
|
}
|
|
104
126
|
}
|
|
105
127
|
/**
|
|
106
|
-
* A write goes on
|
|
107
|
-
* connection with the close still on its way
|
|
108
|
-
*
|
|
128
|
+
* A write goes on the kept connection, or with `fresh` on one of its own. The server may have closed the kept
|
|
129
|
+
* connection while idle, with the close still on its way, so a write sent on it can go unanswered: the error is
|
|
130
|
+
* `broken` (Unreachable), and the client sends it again at once under its requestId, which the server answers with
|
|
131
|
+
* the response it kept if it took the request after all. Keeping the connection busy (warm) makes that rare.
|
|
109
132
|
*/
|
|
110
|
-
|
|
133
|
+
write(method, url, body, fresh) {
|
|
134
|
+
return this.exchange(method, url, {
|
|
135
|
+
...this.headers,
|
|
136
|
+
"content-length": String(Buffer.byteLength(body))
|
|
137
|
+
}, body, fresh ? false : this.agent, this.timeoutMs);
|
|
138
|
+
}
|
|
139
|
+
exchange(method, url, headers, body, agent, timeoutMs) {
|
|
111
140
|
const send = url.startsWith("https:") ? request$1 : request;
|
|
141
|
+
if (agent) this.busy++;
|
|
112
142
|
return new Promise((resolve, reject) => {
|
|
113
143
|
const req = send(url, {
|
|
114
144
|
method,
|
|
115
|
-
agent
|
|
116
|
-
headers
|
|
117
|
-
|
|
118
|
-
"content-length": Buffer.byteLength(body)
|
|
119
|
-
},
|
|
120
|
-
timeout: this.timeoutMs
|
|
145
|
+
agent,
|
|
146
|
+
headers,
|
|
147
|
+
timeout: timeoutMs
|
|
121
148
|
}, (res) => {
|
|
122
149
|
const chunks = [];
|
|
123
150
|
res.on("data", (chunk) => chunks.push(chunk));
|
|
@@ -130,11 +157,27 @@ var ServerApi = class {
|
|
|
130
157
|
req.on("timeout", () => req.destroy(Object.assign(/* @__PURE__ */ new Error("no answer in time"), { code: req.socket?.connecting === false ? "ETIMEDOUT" : "ECONNECT_TIMEOUT" })));
|
|
131
158
|
req.on("error", reject);
|
|
132
159
|
req.end(body);
|
|
160
|
+
}).finally(() => {
|
|
161
|
+
if (agent) this.busy--;
|
|
133
162
|
});
|
|
134
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* Keep the write connection open: a request that needs no hub, on it, unless a request is on it already. Opens it
|
|
166
|
+
* when there is none, so the first write after connecting finds one. A failure is the next write's to find.
|
|
167
|
+
*/
|
|
168
|
+
async warm() {
|
|
169
|
+
if (this.busy > 0) return;
|
|
170
|
+
try {
|
|
171
|
+
await this.exchange("GET", `${this.settings.url}${ROUTES.version}`, {}, void 0, this.agent, WARM_TIMEOUT_MS);
|
|
172
|
+
} catch {}
|
|
173
|
+
}
|
|
174
|
+
/** Close the kept connection; a later request opens another. */
|
|
175
|
+
close() {
|
|
176
|
+
this.agent.destroy();
|
|
177
|
+
}
|
|
135
178
|
/** Send one request; the response is the hub's answer, applied or refused. */
|
|
136
|
-
async send(request) {
|
|
137
|
-
return (await this.call("POST", ROUTES.request(this.settings.hub), request)).body;
|
|
179
|
+
async send(request, options = {}) {
|
|
180
|
+
return (await this.call("POST", ROUTES.request(this.settings.hub), request, options.fresh === true)).body;
|
|
138
181
|
}
|
|
139
182
|
/** Every file of the hub, as of the hub's `seq`. */
|
|
140
183
|
async snapshot() {
|
|
@@ -145,14 +188,17 @@ var ServerApi = class {
|
|
|
145
188
|
files: body.files
|
|
146
189
|
};
|
|
147
190
|
}
|
|
148
|
-
/**
|
|
191
|
+
/**
|
|
192
|
+
* Upload a whole hub; refused with 409 when the server already holds one and `replace` is not set. It goes on a
|
|
193
|
+
* connection of its own, not the kept one, since it has no requestId to be sent again under when its connection breaks.
|
|
194
|
+
*/
|
|
149
195
|
async upload(files, dirs, replace = false) {
|
|
150
196
|
const { body } = await this.call("PUT", ROUTES.snapshot(this.settings.hub), {
|
|
151
197
|
protocol: 2,
|
|
152
198
|
files,
|
|
153
199
|
dirs,
|
|
154
200
|
replace
|
|
155
|
-
});
|
|
201
|
+
}, true);
|
|
156
202
|
return body;
|
|
157
203
|
}
|
|
158
204
|
/** Open the hub's broadcast connection. */
|
|
@@ -199,11 +245,70 @@ function alignLines(a, b) {
|
|
|
199
245
|
keptB
|
|
200
246
|
};
|
|
201
247
|
}
|
|
202
|
-
/**
|
|
203
|
-
|
|
248
|
+
/**
|
|
249
|
+
* How many lines differ between two texts: the lines only in a plus the lines only in b, as alignLines leaves them.
|
|
250
|
+
* Found by growing the number of differing lines one at a time (Myers' O(ND) difference), so two texts a few lines
|
|
251
|
+
* apart cost about one pass over their lines, however long they are. With `limit`, a distance of `limit` or more is
|
|
252
|
+
* not worked out: `limit` is returned.
|
|
253
|
+
*/
|
|
254
|
+
function lineDistance(a, b, limit = Infinity) {
|
|
204
255
|
if (a === b) return 0;
|
|
205
|
-
const
|
|
206
|
-
|
|
256
|
+
const x = split(a);
|
|
257
|
+
const y = split(b);
|
|
258
|
+
let start = 0;
|
|
259
|
+
while (start < x.length && start < y.length && x[start] === y[start]) start++;
|
|
260
|
+
let endX = x.length;
|
|
261
|
+
let endY = y.length;
|
|
262
|
+
while (endX > start && endY > start && x[endX - 1] === y[endY - 1]) {
|
|
263
|
+
endX--;
|
|
264
|
+
endY--;
|
|
265
|
+
}
|
|
266
|
+
const n = endX - start;
|
|
267
|
+
const m = endY - start;
|
|
268
|
+
const most = Math.min(n + m, limit);
|
|
269
|
+
if (n === 0 || m === 0) return Math.min(n + m, limit);
|
|
270
|
+
const offset = most + 1;
|
|
271
|
+
const furthest = new Int32Array(2 * most + 3);
|
|
272
|
+
for (let d = 0; d <= most; d++) for (let k = -d; k <= d; k += 2) {
|
|
273
|
+
let i = k === -d || k !== d && furthest[offset + k - 1] < furthest[offset + k + 1] ? furthest[offset + k + 1] : furthest[offset + k - 1] + 1;
|
|
274
|
+
let j = i - k;
|
|
275
|
+
while (i < n && j < m && x[start + i] === y[start + j]) {
|
|
276
|
+
i++;
|
|
277
|
+
j++;
|
|
278
|
+
}
|
|
279
|
+
furthest[offset + k] = i;
|
|
280
|
+
if (i >= n && j >= m) return d;
|
|
281
|
+
}
|
|
282
|
+
return limit;
|
|
283
|
+
}
|
|
284
|
+
/** How many lines `text` has, as lineDistance counts them. */
|
|
285
|
+
function lineCount(text) {
|
|
286
|
+
let count = 1;
|
|
287
|
+
for (let at = text.indexOf("\n"); at >= 0; at = text.indexOf("\n", at + 1)) count++;
|
|
288
|
+
return count;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* The version among `versions`, oldest first, with the fewest lines differing from `text` (lineDistance), the newest
|
|
292
|
+
* on a tie; undefined when there is none. The versions are compared newest first, and one whose line count alone puts
|
|
293
|
+
* it as far from `text` as the closest so far is not compared at all: the lines that differ are at least the
|
|
294
|
+
* difference in line counts, and an older version must be strictly closer to be chosen; nor is a comparison carried
|
|
295
|
+
* past the closest distance so far. So a writer that added or changed a line of the version on disk costs about one
|
|
296
|
+
* pass over the file per version left to compare, however long the file is.
|
|
297
|
+
*/
|
|
298
|
+
function closestVersion(versions, text) {
|
|
299
|
+
const lines = lineCount(text);
|
|
300
|
+
let best;
|
|
301
|
+
let bestDistance = Infinity;
|
|
302
|
+
for (let i = versions.length - 1; i >= 0 && bestDistance > 0; i--) {
|
|
303
|
+
const version = versions[i];
|
|
304
|
+
if (Math.abs(lineCount(version.text) - lines) >= bestDistance) continue;
|
|
305
|
+
const distance = lineDistance(version.text, text, bestDistance);
|
|
306
|
+
if (distance < bestDistance) {
|
|
307
|
+
best = version;
|
|
308
|
+
bestDistance = distance;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return best;
|
|
207
312
|
}
|
|
208
313
|
//#endregion
|
|
209
314
|
//#region ../sync/dist/merge.js
|
|
@@ -243,6 +348,8 @@ const syncPaths = (root) => {
|
|
|
243
348
|
tmp: join(own, "tmp"),
|
|
244
349
|
conflicts: join(own, "conflicts"),
|
|
245
350
|
known: join(own, "known.json"),
|
|
351
|
+
/** The text of each version known.json names, in a file named by its content hash */
|
|
352
|
+
knownTexts: join(own, "known"),
|
|
246
353
|
queue: join(own, "queue.json"),
|
|
247
354
|
notices: join(own, "notices.jsonl"),
|
|
248
355
|
daemon: join(own, "daemon.json"),
|
|
@@ -273,8 +380,15 @@ function isSynced(path) {
|
|
|
273
380
|
}
|
|
274
381
|
/** Whether the directory at `path` may hold synced files, so a scan walks into it. */
|
|
275
382
|
const mayHoldSynced = (path) => path !== "local" ? !path.startsWith("local/") || path === "local/sessions" : true;
|
|
383
|
+
/**
|
|
384
|
+
* Whether the client's file watcher watches inside the directory at `path`: one that may hold synced files, and
|
|
385
|
+
* local/sync/ itself for the outbox; the directories under local/sync/ hold only the client's own files.
|
|
386
|
+
*/
|
|
387
|
+
const watchedDir = (path) => path === "local/sync" || mayHoldSynced(path);
|
|
276
388
|
//#endregion
|
|
277
389
|
//#region ../sync/dist/state.js
|
|
390
|
+
/** Whether `entry` waits for the server: not one it refused. */
|
|
391
|
+
const waiting = (entry) => entry.refused === void 0;
|
|
278
392
|
/** A new entry's id. */
|
|
279
393
|
const entryId = () => randomBytes(8).toString("hex");
|
|
280
394
|
const entryPaths = (entry) => entry.kind === "op" ? Object.keys(entry.written) : [entry.path];
|
|
@@ -288,7 +402,8 @@ const LOCAL_MINUTE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/;
|
|
|
288
402
|
*
|
|
289
403
|
* Around a daylight saving change the minute is read as Date reads a local time. A minute that occurs twice, in the
|
|
290
404
|
* hour an autumn change repeats, always gets the earlier offset, the one before the change; the minute itself is
|
|
291
|
-
* kept
|
|
405
|
+
* kept. The server writes the whole `now`, so that earlier offset is what lands in the hub's files: an hour off from the
|
|
406
|
+
* instant the operation really ran, for a minute in that repeated hour that ran after the change. A minute in the
|
|
292
407
|
* hour a spring change skips would move an hour forward, but a local clock never shows one, so none is queued.
|
|
293
408
|
*/
|
|
294
409
|
function currentRequest(request) {
|
|
@@ -316,7 +431,36 @@ function writeJson(file, value) {
|
|
|
316
431
|
renameSync(temp, file);
|
|
317
432
|
}
|
|
318
433
|
const readKnown = (root) => readJson(syncPaths(root).known, {}).files ?? {};
|
|
319
|
-
|
|
434
|
+
/** The text of the version known.json names by `hash`; undefined when it was not kept, as by a sync client before known/. */
|
|
435
|
+
function readKnownText(root, hash) {
|
|
436
|
+
try {
|
|
437
|
+
const text = readFileSync(join(syncPaths(root).knownTexts, hash), "utf8");
|
|
438
|
+
return contentHash(text) === hash ? text : void 0;
|
|
439
|
+
} catch {
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Write known.json, and into known/ the text of each version it names that is not
|
|
445
|
+
* there yet; the texts of versions it no longer names are removed. A version whose text the client does not have keeps
|
|
446
|
+
* the text known/ holds, if any. The new texts go first, then known.json, then the removals, so a client killed at any
|
|
447
|
+
* step leaves a known.json whose every text is in known/.
|
|
448
|
+
*/
|
|
449
|
+
function writeKnown(root, files) {
|
|
450
|
+
const dir = syncPaths(root).knownTexts;
|
|
451
|
+
mkdirSync(dir, { recursive: true });
|
|
452
|
+
const named = new Set(Object.values(files).map((file) => file.hash));
|
|
453
|
+
const held = new Set(readdirSync(dir));
|
|
454
|
+
for (const { hash, text } of Object.values(files)) {
|
|
455
|
+
if (text === void 0 || held.has(hash)) continue;
|
|
456
|
+
writeFileSync(join(dir, `${hash}.tmp`), text);
|
|
457
|
+
renameSync(join(dir, `${hash}.tmp`), join(dir, hash));
|
|
458
|
+
held.add(hash);
|
|
459
|
+
}
|
|
460
|
+
const known = { files: Object.fromEntries(Object.entries(files).map(([path, file]) => [path, file.hash])) };
|
|
461
|
+
writeJson(syncPaths(root).known, known);
|
|
462
|
+
for (const name of held) if (!named.has(name)) rmSync(join(dir, name), { force: true });
|
|
463
|
+
}
|
|
320
464
|
const writeQueue = (root, entries) => writeJson(syncPaths(root).queue, { entries });
|
|
321
465
|
/** The queue as queue.json has it; an entry from before entries had ids gets one, written back. Call under the write lock. */
|
|
322
466
|
function readQueue(root) {
|
|
@@ -336,7 +480,10 @@ const underLock = (root, fn) => nodeStorage(root).lock(fn);
|
|
|
336
480
|
const HELPER_SOURCE = "mainflow-swap.c";
|
|
337
481
|
/** What `mainflow-swap --version` prints; another answer is a helper this client does not speak to. */
|
|
338
482
|
const HELPER_PROTOCOL = "mainflow-swap 1";
|
|
339
|
-
/**
|
|
483
|
+
/**
|
|
484
|
+
* The file name of a prebuilt helper. A release ships darwin-arm64, darwin-x64, linux-arm64 and linux-x64;
|
|
485
|
+
* a local build on macOS makes one darwin-universal file instead.
|
|
486
|
+
*/
|
|
340
487
|
const prebuiltName = (platform, arch) => `mainflow-swap-${platform}-${arch}`;
|
|
341
488
|
/** Where the native/ directory is: beside this module in the built package, or the source tree's dist/native. */
|
|
342
489
|
function nativeDirs() {
|
|
@@ -362,11 +509,36 @@ const works = (file) => {
|
|
|
362
509
|
return false;
|
|
363
510
|
}
|
|
364
511
|
};
|
|
365
|
-
const cacheDir = () => join(
|
|
512
|
+
const cacheDir = (env = process.env) => join(env.XDG_CACHE_HOME || join(homedir(), ".cache"), "mainflow");
|
|
513
|
+
const isExecutable = (file) => {
|
|
514
|
+
try {
|
|
515
|
+
accessSync(file, constants.X_OK);
|
|
516
|
+
return true;
|
|
517
|
+
} catch {
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
/** Copy a prebuilt helper that lost its execute bit into the cache directory as 0755; null when that does not work. */
|
|
522
|
+
function executableCopy(file, env) {
|
|
523
|
+
const text = readFileSync(file);
|
|
524
|
+
const target = join(cacheDir(env), `${basename(file)}-${createHash("sha1").update(text).digest("hex").slice(0, 12)}`);
|
|
525
|
+
if (existsSync(target) && isExecutable(target) && works(target)) return target;
|
|
526
|
+
const temp = `${target}.${process.pid}.tmp`;
|
|
527
|
+
try {
|
|
528
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
529
|
+
copyFileSync(file, temp);
|
|
530
|
+
chmodSync(temp, 493);
|
|
531
|
+
renameSync(temp, target);
|
|
532
|
+
return works(target) ? target : null;
|
|
533
|
+
} catch {
|
|
534
|
+
rmSync(temp, { force: true });
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
366
538
|
/** Compile the shipped source into the cache directory; null when there is no source or no working compiler. */
|
|
367
|
-
function compile(source) {
|
|
539
|
+
function compile(source, env) {
|
|
368
540
|
const text = readFileSync(source);
|
|
369
|
-
const target = join(cacheDir(), `${prebuiltName(process.platform, process.arch)}-${createHash("sha1").update(text).digest("hex").slice(0, 12)}`);
|
|
541
|
+
const target = join(cacheDir(env), `${prebuiltName(process.platform, process.arch)}-${createHash("sha1").update(text).digest("hex").slice(0, 12)}`);
|
|
370
542
|
if (existsSync(target) && works(target)) return target;
|
|
371
543
|
mkdirSync(dirname(target), { recursive: true });
|
|
372
544
|
const temp = `${target}.${process.pid}.tmp`;
|
|
@@ -388,7 +560,7 @@ function compile(source) {
|
|
|
388
560
|
}
|
|
389
561
|
}
|
|
390
562
|
/** Find a working helper for this machine; see the top of this file for the order. */
|
|
391
|
-
function findHelper(env = process.env) {
|
|
563
|
+
function findHelper(env = process.env, dirs = nativeDirs()) {
|
|
392
564
|
if (process.platform !== "darwin" && process.platform !== "linux") return {
|
|
393
565
|
path: null,
|
|
394
566
|
source: "none"
|
|
@@ -402,17 +574,26 @@ function findHelper(env = process.env) {
|
|
|
402
574
|
source: "none"
|
|
403
575
|
};
|
|
404
576
|
const names = [prebuiltName(process.platform, process.arch), prebuiltName(process.platform, "universal")];
|
|
405
|
-
for (const dir of
|
|
577
|
+
for (const dir of dirs) for (const name of names) {
|
|
406
578
|
const file = join(dir, name);
|
|
407
|
-
if (existsSync(file)
|
|
408
|
-
|
|
579
|
+
if (!existsSync(file)) continue;
|
|
580
|
+
if (isExecutable(file)) {
|
|
581
|
+
if (works(file)) return {
|
|
582
|
+
path: file,
|
|
583
|
+
source: "prebuilt"
|
|
584
|
+
};
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
const copy = executableCopy(file, env);
|
|
588
|
+
if (copy) return {
|
|
589
|
+
path: copy,
|
|
409
590
|
source: "prebuilt"
|
|
410
591
|
};
|
|
411
592
|
}
|
|
412
|
-
for (const dir of
|
|
593
|
+
for (const dir of dirs) {
|
|
413
594
|
const source = join(dir, HELPER_SOURCE);
|
|
414
595
|
if (!existsSync(source)) continue;
|
|
415
|
-
const compiled = compile(source);
|
|
596
|
+
const compiled = compile(source, env);
|
|
416
597
|
if (compiled) return {
|
|
417
598
|
path: compiled,
|
|
418
599
|
source: "compiled"
|
|
@@ -490,11 +671,128 @@ function startSwapper(dir, env = process.env) {
|
|
|
490
671
|
}
|
|
491
672
|
}
|
|
492
673
|
//#endregion
|
|
493
|
-
//#region ../sync/dist/
|
|
674
|
+
//#region ../sync/dist/watch.js
|
|
675
|
+
/** The mode for this platform: `recursive` where fs.watch watches a tree natively, `directories` elsewhere, such as Linux. */
|
|
676
|
+
const PLATFORM_MODE = process.platform === "darwin" || process.platform === "win32" ? "recursive" : "directories";
|
|
677
|
+
/** Watch the tree at `root`, an existing directory. Throws when the root cannot be watched. */
|
|
678
|
+
function watchTree(root, options) {
|
|
679
|
+
return (options.mode ?? PLATFORM_MODE) === "recursive" ? recursiveWatch(root, options) : new DirectoryWatcher(root, options);
|
|
680
|
+
}
|
|
681
|
+
const childOf = (dir, name) => dir === "" ? name : `${dir}/${name}`;
|
|
682
|
+
function recursiveWatch(root, { include, onChange, onError }) {
|
|
683
|
+
const included = (path) => {
|
|
684
|
+
for (let at = path.indexOf("/"); at >= 0; at = path.indexOf("/", at + 1)) if (!include(path.slice(0, at))) return false;
|
|
685
|
+
return include("");
|
|
686
|
+
};
|
|
687
|
+
const watcher = watch(root, { recursive: true }, (_event, name) => {
|
|
688
|
+
if (!name) return;
|
|
689
|
+
const path = String(name).split(sep).join("/");
|
|
690
|
+
if (included(path)) onChange(path);
|
|
691
|
+
});
|
|
692
|
+
watcher.on("error", (error) => onError(error, "", true));
|
|
693
|
+
return {
|
|
694
|
+
mode: "recursive",
|
|
695
|
+
directories: () => [""],
|
|
696
|
+
close: () => watcher.close()
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
var DirectoryWatcher = class {
|
|
700
|
+
root;
|
|
701
|
+
options;
|
|
702
|
+
mode = "directories";
|
|
703
|
+
watched = /* @__PURE__ */ new Map();
|
|
704
|
+
closed = false;
|
|
705
|
+
constructor(root, options) {
|
|
706
|
+
this.root = root;
|
|
707
|
+
this.options = options;
|
|
708
|
+
const error = this.add("", false);
|
|
709
|
+
if (error) throw error;
|
|
710
|
+
}
|
|
711
|
+
directories() {
|
|
712
|
+
return [...this.watched.keys()];
|
|
713
|
+
}
|
|
714
|
+
close() {
|
|
715
|
+
this.closed = true;
|
|
716
|
+
this.drop("");
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Watch `dir` and the directories in it. With `report`, each file found in them is reported, since it may have been
|
|
720
|
+
* written before its directory was watched. Returns the error when `dir` itself cannot be watched.
|
|
721
|
+
*/
|
|
722
|
+
add(dir, report) {
|
|
723
|
+
if (this.closed || this.watched.has(dir) || !this.options.include(dir)) return;
|
|
724
|
+
const full = join(this.root, dir);
|
|
725
|
+
let entries;
|
|
726
|
+
try {
|
|
727
|
+
if (!(dir === "" ? statSync(full) : lstatSync(full)).isDirectory()) throw Object.assign(/* @__PURE__ */ new Error(`not a directory: ${full}`), { code: "ENOTDIR" });
|
|
728
|
+
const watcher = watch(full, (event, name) => this.event(dir, watcher, event, name));
|
|
729
|
+
watcher.on("error", (error) => {
|
|
730
|
+
if (this.watched.get(dir) !== watcher) return;
|
|
731
|
+
this.drop(dir);
|
|
732
|
+
this.options.onError(error, dir, true);
|
|
733
|
+
});
|
|
734
|
+
this.watched.set(dir, watcher);
|
|
735
|
+
entries = readdirSync(full, { withFileTypes: true });
|
|
736
|
+
} catch (error) {
|
|
737
|
+
this.drop(dir);
|
|
738
|
+
if (dir === "") return error;
|
|
739
|
+
const code = error.code;
|
|
740
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") this.options.onError(error, dir, false);
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
for (const entry of entries) {
|
|
744
|
+
const path = childOf(dir, entry.name);
|
|
745
|
+
if (entry.isDirectory()) this.add(path, report);
|
|
746
|
+
else if (report) this.options.onChange(path);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
event(dir, watcher, event, name) {
|
|
750
|
+
if (this.watched.get(dir) !== watcher || !name) return;
|
|
751
|
+
const path = childOf(dir, name);
|
|
752
|
+
if (event === "rename") {
|
|
753
|
+
if (dir !== "" && name === basename(dir) && !exists(join(this.root, path))) this.renamed(dir);
|
|
754
|
+
else this.renamed(path);
|
|
755
|
+
}
|
|
756
|
+
this.options.onChange(path);
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* `path` appeared or went away. A directory watched there is let go, since it may be gone, or another one put in its
|
|
760
|
+
* place, which can even have the same inode; whatever directory is there now is watched, with its files reported.
|
|
761
|
+
*/
|
|
762
|
+
renamed(path) {
|
|
763
|
+
const watched = this.watched.has(path);
|
|
764
|
+
let directory = false;
|
|
765
|
+
try {
|
|
766
|
+
directory = lstatSync(join(this.root, path)).isDirectory();
|
|
767
|
+
} catch {}
|
|
768
|
+
if (watched) this.drop(path);
|
|
769
|
+
if (directory) this.add(path, true);
|
|
770
|
+
}
|
|
771
|
+
/** Stop watching `dir` and every directory under it. */
|
|
772
|
+
drop(dir) {
|
|
773
|
+
for (const [path, watcher] of this.watched) {
|
|
774
|
+
if (dir !== "" && path !== dir && !path.startsWith(`${dir}/`)) continue;
|
|
775
|
+
watcher.close();
|
|
776
|
+
this.watched.delete(path);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
function exists(file) {
|
|
781
|
+
try {
|
|
782
|
+
lstatSync(file);
|
|
783
|
+
return true;
|
|
784
|
+
} catch {
|
|
785
|
+
return false;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
494
788
|
const HISTORY = 20;
|
|
495
789
|
/** A text's short content hash, for traces. */
|
|
496
790
|
const short = (text) => text == null ? String(text) : contentHash(text).slice(0, 7);
|
|
497
|
-
|
|
791
|
+
/** How long after the file watcher's last event for a path the path is looked at, so a write in progress is not sent half done */
|
|
792
|
+
const SETTLE_MS = 20;
|
|
793
|
+
/** How often whenSettled compares a file's size and modification time, and how long it does so at most */
|
|
794
|
+
const SETTLE_POLL_MS = 5;
|
|
795
|
+
const SETTLE_LIMIT_MS = 1e3;
|
|
498
796
|
/**
|
|
499
797
|
* Snapshots in a row that bring neither a missing change nor anything newer before the change counts as lost; the
|
|
500
798
|
* ones before the last are each followed by a pause before the next
|
|
@@ -509,6 +807,14 @@ const BINARY_RECHECK_MS = 150;
|
|
|
509
807
|
const EMPTY_RECHECK_MS = 300;
|
|
510
808
|
/** How long after the exchange the displaced file is read again, for a program that still held it open */
|
|
511
809
|
const LATE_WRITE_MS = 150;
|
|
810
|
+
/**
|
|
811
|
+
* How long the client exchanges files one after another before the event loop gets a turn, so a sync client exchanging
|
|
812
|
+
* many files at once, as after a large snapshot, still answers /status and the other requests of its port
|
|
813
|
+
*/
|
|
814
|
+
const SWAP_SLICE_MS = 50;
|
|
815
|
+
/** Failures of the atomic exchange helper in a row after which files are written with a rename for SWAP_PAUSE_MS */
|
|
816
|
+
const SWAP_FAILURES = 3;
|
|
817
|
+
const SWAP_PAUSE_MS = 6e4;
|
|
512
818
|
/** queue.json, relative to .mainflow/: other writers append to it, and the client sends what they appended */
|
|
513
819
|
const OUTBOX = `${SYNC_DIR}/queue.json`;
|
|
514
820
|
const sleep$1 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -556,9 +862,26 @@ function heldBase(entry) {
|
|
|
556
862
|
} : void 0;
|
|
557
863
|
}
|
|
558
864
|
/**
|
|
559
|
-
* The
|
|
560
|
-
*
|
|
561
|
-
|
|
865
|
+
* The text `entry` holds for `path`: a text, null for no file, undefined for a path it does not write. A displaced
|
|
866
|
+
* text, one the server's version took off disk (src/state.ts), is included; it is what a refusal saves.
|
|
867
|
+
*/
|
|
868
|
+
function textAt(entry, path) {
|
|
869
|
+
if (entry.kind === "op") return entry.written[path];
|
|
870
|
+
if (entry.path !== path) return void 0;
|
|
871
|
+
if (entry.kind === "remove") return null;
|
|
872
|
+
return entry.text;
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* What `entry` left on disk at `path` when it was queued: a text, null for no file, undefined for nothing, as for a
|
|
876
|
+
* path it does not write or a displaced text, which the server's version took off disk.
|
|
877
|
+
*/
|
|
878
|
+
function wroteAt(entry, path) {
|
|
879
|
+
return entry.kind === "patch" && entry.displaced ? void 0 : textAt(entry, path);
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* The request a queued edit sends: its text on its base, moved first onto the server's merge of its base when the
|
|
883
|
+
* server merged it (`serverBase`), with the base as the common ancestor. `unresolved` while its base is a local text
|
|
884
|
+
* the server has not answered for; `overlap` when the move overlaps.
|
|
562
885
|
*/
|
|
563
886
|
function patchRequest(entry) {
|
|
564
887
|
if (entry.base === null && entry.baseText !== void 0) return "unresolved";
|
|
@@ -583,11 +906,20 @@ var SyncClient = class extends EventEmitter {
|
|
|
583
906
|
api;
|
|
584
907
|
problems = [];
|
|
585
908
|
reported = 0;
|
|
909
|
+
/** What each path holds on disk as the client last left it, confirmed or not */
|
|
586
910
|
expected = /* @__PURE__ */ new Map();
|
|
911
|
+
/**
|
|
912
|
+
* The version of each path the server confirmed that the client last had on disk, as known.json and known/ keep it:
|
|
913
|
+
* a server's version written here, or a writer's text the server answered for while the file still held it
|
|
914
|
+
*/
|
|
915
|
+
confirmed = /* @__PURE__ */ new Map();
|
|
916
|
+
/**
|
|
917
|
+
* The refused outbox entries whose writer this run told, or found told by a run before (resumeRefusals): only these
|
|
918
|
+
* leave the outbox, once no file holds what they wrote (dropSettled)
|
|
919
|
+
*/
|
|
920
|
+
told = /* @__PURE__ */ new Set();
|
|
587
921
|
/** The confirmed versions of each path, oldest first, at most HISTORY; versions() adds what the outbox holds */
|
|
588
922
|
history = /* @__PURE__ */ new Map();
|
|
589
|
-
/** The writer's texts of the direct edits and Edits sent outside the outbox that wait for an answer, by path */
|
|
590
|
-
unanswered = /* @__PURE__ */ new Map();
|
|
591
923
|
cloud = /* @__PURE__ */ new Map();
|
|
592
924
|
/** The hub's seq of each path's version in `cloud`: a version from the server with a seq no higher is not newer */
|
|
593
925
|
fileSeq = /* @__PURE__ */ new Map();
|
|
@@ -597,7 +929,6 @@ var SyncClient = class extends EventEmitter {
|
|
|
597
929
|
ahead = /* @__PURE__ */ new Set();
|
|
598
930
|
waiters = /* @__PURE__ */ new Set();
|
|
599
931
|
inflight = /* @__PURE__ */ new Map();
|
|
600
|
-
dirty = /* @__PURE__ */ new Set();
|
|
601
932
|
chains = /* @__PURE__ */ new Map();
|
|
602
933
|
work = /* @__PURE__ */ new Set();
|
|
603
934
|
binaries = /* @__PURE__ */ new Set();
|
|
@@ -614,18 +945,39 @@ var SyncClient = class extends EventEmitter {
|
|
|
614
945
|
sending = null;
|
|
615
946
|
ws = null;
|
|
616
947
|
connected = false;
|
|
948
|
+
/** Whether a snapshot was taken on the current connection; cleared when a connection opens or is lost, and when one fails */
|
|
949
|
+
current = false;
|
|
950
|
+
/** No reconnecting: stop() or the test hook disconnect() */
|
|
617
951
|
stopped = false;
|
|
952
|
+
/** stop() was called: nothing more is scheduled, taken in, written to disk or sent */
|
|
953
|
+
closing = false;
|
|
618
954
|
flushing = false;
|
|
955
|
+
/** stop() gave up waiting for work under way: that work no longer writes the outbox, known.json or files */
|
|
956
|
+
abandoned = false;
|
|
957
|
+
/** The send of the outbox under way, which stop() waits for */
|
|
958
|
+
flushRun = null;
|
|
619
959
|
resyncing = null;
|
|
620
960
|
resyncAgain = false;
|
|
621
961
|
/** Whether a snapshot has been taken, after which a path the server never mentioned does not exist there */
|
|
622
962
|
synced = false;
|
|
623
963
|
reconnectTimer;
|
|
624
964
|
pingTimer;
|
|
965
|
+
/** Keeps the connection writes go on open while connected (ServerApi.warm) */
|
|
966
|
+
warmTimer;
|
|
625
967
|
rescanTimer;
|
|
626
968
|
queueTimer;
|
|
627
969
|
watcher;
|
|
970
|
+
/** The file watcher's timers, one per path waiting to settle */
|
|
971
|
+
watchTimers = /* @__PURE__ */ new Map();
|
|
972
|
+
/** Rereads of displaced files still to come (recheck), which stop() waits for */
|
|
973
|
+
rechecks = /* @__PURE__ */ new Set();
|
|
628
974
|
swapper = null;
|
|
975
|
+
/** The helper's failures in a row, and until when files are written with a rename after SWAP_FAILURES of them */
|
|
976
|
+
swapFailures = 0;
|
|
977
|
+
swapPausedUntil = 0;
|
|
978
|
+
/** The exchange before which the next one waits (exchangeTurn), and when the client last let the event loop run */
|
|
979
|
+
exchanging = Promise.resolve();
|
|
980
|
+
sliceStarted = 0;
|
|
629
981
|
/** Settles once the first connection attempt synced or failed */
|
|
630
982
|
firstContact;
|
|
631
983
|
backoff = 500;
|
|
@@ -652,16 +1004,20 @@ var SyncClient = class extends EventEmitter {
|
|
|
652
1004
|
get online() {
|
|
653
1005
|
return this.connected && !this.stopped;
|
|
654
1006
|
}
|
|
1007
|
+
/** Writes waiting in the outbox for the server: not the ones it refused, which wait for nothing (src/state.ts). */
|
|
655
1008
|
get queued() {
|
|
656
|
-
return this.queue.length;
|
|
1009
|
+
return this.queue.filter(waiting).length;
|
|
657
1010
|
}
|
|
658
|
-
/**
|
|
1011
|
+
/**
|
|
1012
|
+
* Whether the local files have every change the server has: online, with this connection's snapshot taken and not
|
|
1013
|
+
* being retaken (I1).
|
|
1014
|
+
*/
|
|
659
1015
|
get caughtUp() {
|
|
660
|
-
return this.online && this.
|
|
1016
|
+
return this.online && this.current && this.resyncing === null;
|
|
661
1017
|
}
|
|
662
|
-
/** Whether files are written with the atomic exchange. */
|
|
1018
|
+
/** Whether files are written with the atomic exchange: there is a helper, not paused after failing in a row. */
|
|
663
1019
|
get swapping() {
|
|
664
|
-
return this.swapper !== null;
|
|
1020
|
+
return this.swapper !== null && Date.now() >= this.swapPausedUntil;
|
|
665
1021
|
}
|
|
666
1022
|
/**
|
|
667
1023
|
* The file at `path` as the server last had it to this client's knowledge: its text, null when the server has no such
|
|
@@ -685,6 +1041,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
685
1041
|
}
|
|
686
1042
|
await this.restoreKnown();
|
|
687
1043
|
await this.whenFree(() => this.syncQueue());
|
|
1044
|
+
await this.resumeRefusals();
|
|
688
1045
|
if (this.opts.watch !== false) this.startWatcher();
|
|
689
1046
|
await this.scan(true, true);
|
|
690
1047
|
const every = this.opts.rescanMs ?? 2e3;
|
|
@@ -693,35 +1050,85 @@ var SyncClient = class extends EventEmitter {
|
|
|
693
1050
|
await this.firstContact;
|
|
694
1051
|
}
|
|
695
1052
|
/**
|
|
696
|
-
* What the last run left on disk, from known.json
|
|
697
|
-
* a local edit can be based on (layer 1) as it was before the restart
|
|
698
|
-
*
|
|
1053
|
+
* What the last run left on disk, from known.json: the version the server confirmed, with its text from known/. It
|
|
1054
|
+
* is a version a local edit can be based on (layer 1) as it was before the restart, whether the file still holds it
|
|
1055
|
+
* or was changed since; without it, a program that read the file before a change from the server arrived would have
|
|
1056
|
+
* its write based on that change and undo it, and a change made while no client ran would have no base text to tell
|
|
1057
|
+
* the writer's lines from another environment's (layer 2). Without its text, as known.json of an older client, only
|
|
1058
|
+
* a file that still holds it gives it. What the outbox wrote, refused writes included, is read after it (restoreEntry).
|
|
699
1059
|
*/
|
|
700
1060
|
async restoreKnown() {
|
|
701
1061
|
for (const [path, hash] of Object.entries(readKnown(this.root))) {
|
|
702
1062
|
const local = await this.readLocal(path);
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
1063
|
+
const text = local.kind === "text" && contentHash(local.text) === hash ? local.text : readKnownText(this.root, hash);
|
|
1064
|
+
const disk = text === void 0 ? { hash } : {
|
|
1065
|
+
hash,
|
|
1066
|
+
text
|
|
1067
|
+
};
|
|
1068
|
+
this.expected.set(path, disk);
|
|
1069
|
+
this.confirmed.set(path, disk);
|
|
1070
|
+
if (text !== void 0) this.remember(path, {
|
|
1071
|
+
text,
|
|
1072
|
+
server: text
|
|
1073
|
+
});
|
|
713
1074
|
}
|
|
714
1075
|
}
|
|
1076
|
+
/**
|
|
1077
|
+
* The refusals a run killed before it settled them left in the outbox (src/state.ts): each text not saved yet is saved,
|
|
1078
|
+
* and the writer is told what notices.jsonl does not have under the entry's id; an operation's writer gets its answer.
|
|
1079
|
+
* Nothing is sent again. An entry whose files no longer hold what it wrote leaves the outbox; the others stay until
|
|
1080
|
+
* the server's version replaces what they wrote (refuse).
|
|
1081
|
+
*/
|
|
1082
|
+
async resumeRefusals() {
|
|
1083
|
+
const refused = this.queue.filter((entry) => entry.refused !== void 0);
|
|
1084
|
+
if (refused.length === 0) return;
|
|
1085
|
+
const before = this.toldBefore();
|
|
1086
|
+
for (const entry of refused) await this.tell(entry, (problem) => before.has(`${entry.id}\0${problem.kind}\0${problem.path}`));
|
|
1087
|
+
await this.whenFree(() => this.editQueue((entries) => this.dropSettled(entries)));
|
|
1088
|
+
}
|
|
1089
|
+
/** What notices.jsonl says the writer was told of each refused outbox entry, as `id\0kind\0path`. */
|
|
1090
|
+
toldBefore() {
|
|
1091
|
+
const out = /* @__PURE__ */ new Set();
|
|
1092
|
+
let lines;
|
|
1093
|
+
try {
|
|
1094
|
+
lines = readFileSync(this.paths.notices, "utf8").split("\n");
|
|
1095
|
+
} catch {
|
|
1096
|
+
return out;
|
|
1097
|
+
}
|
|
1098
|
+
for (const line of lines) try {
|
|
1099
|
+
const notice = JSON.parse(line);
|
|
1100
|
+
if (typeof notice.entry === "string") out.add(`${notice.entry}\0${String(notice.kind)}\0${String(notice.path)}`);
|
|
1101
|
+
} catch {}
|
|
1102
|
+
return out;
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Stop: nothing more is scheduled, taken in from the server, written to disk or sent, whatever timer or answer comes
|
|
1106
|
+
* later. Work already under way ends first: a reconcile, and the reread of a displaced file, whose late write is
|
|
1107
|
+
* queued, since no later run can find it.
|
|
1108
|
+
*/
|
|
715
1109
|
async stop() {
|
|
1110
|
+
this.closing = true;
|
|
716
1111
|
this.stopped = true;
|
|
717
1112
|
clearTimeout(this.reconnectTimer);
|
|
718
1113
|
clearInterval(this.pingTimer);
|
|
1114
|
+
clearInterval(this.warmTimer);
|
|
719
1115
|
clearInterval(this.rescanTimer);
|
|
720
1116
|
clearTimeout(this.queueTimer);
|
|
1117
|
+
for (const timer of this.watchTimers.values()) clearTimeout(timer);
|
|
1118
|
+
this.watchTimers.clear();
|
|
721
1119
|
this.watcher?.close();
|
|
722
1120
|
this.ws?.close();
|
|
723
|
-
|
|
724
|
-
|
|
1121
|
+
const settled = Promise.allSettled([
|
|
1122
|
+
...this.rechecks,
|
|
1123
|
+
...this.chains.values(),
|
|
1124
|
+
...this.flushRun ? [this.flushRun] : []
|
|
1125
|
+
]);
|
|
1126
|
+
let timer;
|
|
1127
|
+
const limit = new Promise((resolve) => timer = setTimeout(() => resolve(false), this.opts.stopWaitMs ?? 5e3));
|
|
1128
|
+
if (!await Promise.race([settled.then(() => true), limit])) this.abandoned = true;
|
|
1129
|
+
clearTimeout(timer);
|
|
1130
|
+
this.api.close();
|
|
1131
|
+
this.saveKnownNow(true);
|
|
725
1132
|
}
|
|
726
1133
|
/** Test hook: drop the connection as a lost network would, and stay offline until reconnect(). */
|
|
727
1134
|
disconnect() {
|
|
@@ -744,7 +1151,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
744
1151
|
const deadline = Date.now() + timeoutMs;
|
|
745
1152
|
this.outboxChanged();
|
|
746
1153
|
for (;;) {
|
|
747
|
-
if (!(this.work.size > 0 || [...this.inflight.values()].some((n) => n > 0) || this.flushing || this.resyncing !== null || this.online && this.
|
|
1154
|
+
if (!(this.work.size > 0 || [...this.inflight.values()].some((n) => n > 0) || this.flushing || this.resyncing !== null || this.online && this.queued > 0)) return true;
|
|
748
1155
|
if (Date.now() > deadline) return false;
|
|
749
1156
|
await (this.work.size > 0 ? Promise.race([Promise.allSettled(this.work), sleep$1(50)]) : sleep$1(50));
|
|
750
1157
|
}
|
|
@@ -756,18 +1163,20 @@ var SyncClient = class extends EventEmitter {
|
|
|
756
1163
|
this.ahead.clear();
|
|
757
1164
|
let opened = false;
|
|
758
1165
|
ws.addEventListener("open", () => {
|
|
1166
|
+
if (this.closing) return ws.close();
|
|
759
1167
|
opened = true;
|
|
760
1168
|
this.diagnosed = false;
|
|
761
1169
|
this.connected = true;
|
|
1170
|
+
this.current = false;
|
|
762
1171
|
this.backoff = 500;
|
|
763
1172
|
this.lastPong = Date.now();
|
|
764
1173
|
clearInterval(this.pingTimer);
|
|
765
1174
|
this.pingTimer = setInterval(() => this.ping(ws), 15e3);
|
|
1175
|
+
clearInterval(this.warmTimer);
|
|
1176
|
+
this.api.warm();
|
|
1177
|
+
this.warmTimer = setInterval(() => void this.api.warm(), WARM_MS);
|
|
766
1178
|
this.log("connected");
|
|
767
|
-
this.track(this.resync().then(() => this.flushQueue()).then(() => this.emit("synced")).catch((error) => {
|
|
768
|
-
this.log(`could not sync: ${error.message}`);
|
|
769
|
-
ws.close();
|
|
770
|
-
}));
|
|
1179
|
+
this.track(this.resync().then(() => this.flushQueue()).then(() => this.emit("synced")).catch((error) => this.log(`could not sync: ${error.message}`)));
|
|
771
1180
|
});
|
|
772
1181
|
ws.addEventListener("message", (event) => {
|
|
773
1182
|
if (this.ws === ws) this.onMessage(String(event.data));
|
|
@@ -794,7 +1203,9 @@ var SyncClient = class extends EventEmitter {
|
|
|
794
1203
|
markOffline() {
|
|
795
1204
|
const was = this.connected;
|
|
796
1205
|
this.connected = false;
|
|
1206
|
+
this.current = false;
|
|
797
1207
|
clearInterval(this.pingTimer);
|
|
1208
|
+
clearInterval(this.warmTimer);
|
|
798
1209
|
for (const waiter of this.waiters) waiter.done();
|
|
799
1210
|
this.waiters.clear();
|
|
800
1211
|
if (was) this.log("offline");
|
|
@@ -817,6 +1228,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
817
1228
|
} catch {}
|
|
818
1229
|
}
|
|
819
1230
|
onMessage(data) {
|
|
1231
|
+
if (this.closing) return;
|
|
820
1232
|
if (data === "pong") {
|
|
821
1233
|
this.lastPong = Date.now();
|
|
822
1234
|
return;
|
|
@@ -902,7 +1314,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
902
1314
|
this.waiters.delete(waiter);
|
|
903
1315
|
this.log(`no broadcast of ${path} after its response; reloading the snapshot`);
|
|
904
1316
|
this.resync().catch(() => {}).finally(done);
|
|
905
|
-
}, 1e4);
|
|
1317
|
+
}, this.opts.deliveryMs ?? 1e4);
|
|
906
1318
|
await arrived;
|
|
907
1319
|
clearTimeout(timer);
|
|
908
1320
|
}
|
|
@@ -912,9 +1324,12 @@ var SyncClient = class extends EventEmitter {
|
|
|
912
1324
|
* reconciled: the server broadcasts a change after storing it, so a snapshot asked for later holds it. One that
|
|
913
1325
|
* still does not, and brought nothing newer, is asked for again after a pause; after STALLED_SNAPSHOTS of them the
|
|
914
1326
|
* server is taken to have lost those changes, as when its storage was replaced: the versions the client holds from
|
|
915
|
-
* after the gap are forgotten and the snapshot is taken once more, as it is.
|
|
1327
|
+
* after the gap are forgotten and the snapshot is taken once more, as it is. The connection has its snapshot once
|
|
1328
|
+
* one ends while it is open (I1); one that fails leaves it without, and the client reconnects to take a fresh one,
|
|
1329
|
+
* rather than send on the snapshot of an earlier connection.
|
|
916
1330
|
*/
|
|
917
1331
|
resync() {
|
|
1332
|
+
if (this.closing) return Promise.resolve();
|
|
918
1333
|
if (this.resyncing) {
|
|
919
1334
|
this.resyncAgain = true;
|
|
920
1335
|
return this.resyncing;
|
|
@@ -940,10 +1355,15 @@ var SyncClient = class extends EventEmitter {
|
|
|
940
1355
|
this.log(`missed the hub's changes ${missing} while taking the snapshot; reloading it`);
|
|
941
1356
|
if (stalled > 0) await sleep$1(250 * 2 ** (stalled - 1));
|
|
942
1357
|
this.resyncAgain = true;
|
|
943
|
-
} while (this.resyncAgain);
|
|
1358
|
+
} while (this.resyncAgain && !this.closing);
|
|
1359
|
+
this.current = this.connected && !this.closing;
|
|
1360
|
+
} catch (error) {
|
|
1361
|
+
this.current = false;
|
|
1362
|
+
this.ws?.close();
|
|
1363
|
+
throw error;
|
|
944
1364
|
} finally {
|
|
945
1365
|
this.resyncing = null;
|
|
946
|
-
if (this.connected && this.
|
|
1366
|
+
if (this.connected && this.queued > 0) this.track(this.flushQueue());
|
|
947
1367
|
}
|
|
948
1368
|
})();
|
|
949
1369
|
return this.resyncing;
|
|
@@ -960,6 +1380,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
960
1380
|
snapshot = await this.api.snapshot();
|
|
961
1381
|
if (upload > 0 || snapshot.files.some((event) => event.kind === "file") || !await this.uploadIfEmpty()) break;
|
|
962
1382
|
}
|
|
1383
|
+
if (this.closing) return;
|
|
963
1384
|
const { seq, files } = snapshot;
|
|
964
1385
|
if (seq < before) {
|
|
965
1386
|
this.log(`the sync server's seq went back from ${before} to ${seq}; taking its snapshot as it is`);
|
|
@@ -1013,8 +1434,9 @@ var SyncClient = class extends EventEmitter {
|
|
|
1013
1434
|
this.log(`uploaded the hub: ${result.files} files`);
|
|
1014
1435
|
return true;
|
|
1015
1436
|
}
|
|
1016
|
-
/** Reconcile `path` after whatever earlier work on it has finished. */
|
|
1437
|
+
/** Reconcile `path` after whatever earlier work on it has finished; nothing once stopping. */
|
|
1017
1438
|
schedule(path) {
|
|
1439
|
+
if (this.closing) return;
|
|
1018
1440
|
const next = (this.chains.get(path) ?? Promise.resolve()).then(() => this.reconcile(path)).catch((error) => this.log(`${path}: ${error.stack ?? String(error)}`));
|
|
1019
1441
|
this.chains.set(path, next);
|
|
1020
1442
|
this.track(next).then(() => {
|
|
@@ -1026,8 +1448,13 @@ var SyncClient = class extends EventEmitter {
|
|
|
1026
1448
|
promise.finally(() => this.work.delete(promise)).catch(() => {});
|
|
1027
1449
|
return promise;
|
|
1028
1450
|
}
|
|
1029
|
-
|
|
1030
|
-
|
|
1451
|
+
/**
|
|
1452
|
+
* Whether a write in the outbox is about `path`, as the outbox was last read, or on its way to the server: the file
|
|
1453
|
+
* keeps what the client wrote there until the server answered (I4), online or not. finish() reconciles it after. A
|
|
1454
|
+
* refused write is answered: the server's version replaces what it wrote.
|
|
1455
|
+
*/
|
|
1456
|
+
pending(path) {
|
|
1457
|
+
return (this.inflight.get(path) ?? 0) > 0 || this.queue.some((entry) => waiting(entry) && entryPaths(entry).includes(path));
|
|
1031
1458
|
}
|
|
1032
1459
|
async readLocal(path) {
|
|
1033
1460
|
let bytes;
|
|
@@ -1051,14 +1478,11 @@ var SyncClient = class extends EventEmitter {
|
|
|
1051
1478
|
}
|
|
1052
1479
|
}
|
|
1053
1480
|
/**
|
|
1054
|
-
* Compare the local file with what the client last left there and with the server's version, and act:
|
|
1055
|
-
* local change, write the server's version, or nothing. A
|
|
1481
|
+
* Compare the local file with what the client last left there and with the server's version, and act: queue a
|
|
1482
|
+
* local change, write the server's version, or nothing. A local change is queued whatever else is in the outbox,
|
|
1483
|
+
* each write an entry of its own; the server's version waits while the outbox has a write about the file (I4).
|
|
1056
1484
|
*/
|
|
1057
1485
|
async reconcile(path) {
|
|
1058
|
-
if (this.isBusy(path)) {
|
|
1059
|
-
this.dirty.add(path);
|
|
1060
|
-
return;
|
|
1061
|
-
}
|
|
1062
1486
|
const local = await this.readLocal(path);
|
|
1063
1487
|
if (local.kind === "binary") {
|
|
1064
1488
|
await sleep$1(BINARY_RECHECK_MS);
|
|
@@ -1083,32 +1507,63 @@ var SyncClient = class extends EventEmitter {
|
|
|
1083
1507
|
}
|
|
1084
1508
|
if (target === local.text) return this.adopt(path, local.text);
|
|
1085
1509
|
if (target === void 0) return;
|
|
1086
|
-
return this.
|
|
1510
|
+
return this.queueLocal(path, local.text, { start: target === null ? null : {
|
|
1087
1511
|
text: local.text,
|
|
1088
1512
|
server: local.text
|
|
1089
1513
|
} });
|
|
1090
1514
|
}
|
|
1091
1515
|
if (!this.matches(disk, local)) {
|
|
1516
|
+
if (local.kind === "text" && target === local.text && !this.pending(path)) {
|
|
1517
|
+
this.adopt(path, local.text);
|
|
1518
|
+
return this.release(path);
|
|
1519
|
+
}
|
|
1092
1520
|
if (local.kind === "text") {
|
|
1093
1521
|
if (local.text === "" && disk?.text) {
|
|
1094
1522
|
await sleep$1(EMPTY_RECHECK_MS);
|
|
1095
1523
|
const again = await this.readLocal(path);
|
|
1096
1524
|
if (again.kind !== "text" || again.text !== "") return this.schedule(path);
|
|
1097
1525
|
}
|
|
1098
|
-
return this.
|
|
1526
|
+
return this.queueLocal(path, local.text);
|
|
1527
|
+
}
|
|
1528
|
+
if (target === null || disk === null) {
|
|
1529
|
+
this.forget(path);
|
|
1530
|
+
this.confirm(path, null);
|
|
1531
|
+
return this.release(path);
|
|
1099
1532
|
}
|
|
1100
|
-
|
|
1101
|
-
return this.sendRemove(path, disk.hash);
|
|
1533
|
+
return this.queueRemove(path, disk);
|
|
1102
1534
|
}
|
|
1103
1535
|
if (target === void 0 || (disk === null ? target === null : target !== null && disk.hash === contentHash(target))) {
|
|
1104
1536
|
if (disk?.text !== void 0) this.remember(path, {
|
|
1105
1537
|
text: disk.text,
|
|
1106
1538
|
server: disk.text
|
|
1107
1539
|
});
|
|
1108
|
-
return;
|
|
1540
|
+
if (target === void 0) return;
|
|
1541
|
+
this.confirm(path, disk);
|
|
1542
|
+
return this.release(path);
|
|
1109
1543
|
}
|
|
1544
|
+
if (this.pending(path)) return;
|
|
1110
1545
|
await this.replace(path, target);
|
|
1111
1546
|
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Whether the file at `path` holds `text` now, null for no file; read at once, for a caller under the write lock.
|
|
1549
|
+
* Decoded as readLocal decodes it, so a file that starts with a byte order mark holds the text read from it.
|
|
1550
|
+
*/
|
|
1551
|
+
onDisk(path, text) {
|
|
1552
|
+
let bytes;
|
|
1553
|
+
try {
|
|
1554
|
+
bytes = readFileSync(join(this.paths.data, path));
|
|
1555
|
+
} catch (error) {
|
|
1556
|
+
const code = error.code;
|
|
1557
|
+
if (code === "ENOENT" || code === "ENOTDIR" || code === "EISDIR") return text === null;
|
|
1558
|
+
throw error;
|
|
1559
|
+
}
|
|
1560
|
+
if (text === null) return false;
|
|
1561
|
+
try {
|
|
1562
|
+
return decoder.decode(bytes) === text;
|
|
1563
|
+
} catch {
|
|
1564
|
+
return false;
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1112
1567
|
matches(disk, local) {
|
|
1113
1568
|
if (disk === null) return local.kind === "absent";
|
|
1114
1569
|
return local.kind === "text" && disk.hash === contentHash(local.text);
|
|
@@ -1122,14 +1577,34 @@ var SyncClient = class extends EventEmitter {
|
|
|
1122
1577
|
text,
|
|
1123
1578
|
server: text
|
|
1124
1579
|
});
|
|
1125
|
-
this.
|
|
1580
|
+
this.confirm(path, {
|
|
1581
|
+
hash: contentHash(text),
|
|
1582
|
+
text
|
|
1583
|
+
});
|
|
1126
1584
|
}
|
|
1127
1585
|
/** A removed file starts over: its earlier versions are gone from the server and are no base for a new one. */
|
|
1128
1586
|
forget(path) {
|
|
1129
1587
|
this.expected.set(path, null);
|
|
1130
1588
|
this.history.delete(path);
|
|
1589
|
+
}
|
|
1590
|
+
/**
|
|
1591
|
+
* The server confirmed that `path` holds `disk`, which the file holds as the client left it: known.json names it from
|
|
1592
|
+
* now on (I4). A version only written here, still in the outbox, is never confirmed.
|
|
1593
|
+
*/
|
|
1594
|
+
confirm(path, disk) {
|
|
1595
|
+
const held = this.confirmed.get(path);
|
|
1596
|
+
if (held === void 0 ? false : held === null ? disk === null : disk !== null && held.hash === disk.hash && (held.text !== void 0 || disk.text === void 0)) return;
|
|
1597
|
+
this.confirmed.set(path, disk);
|
|
1131
1598
|
this.saveKnown();
|
|
1132
1599
|
}
|
|
1600
|
+
/** The writer's `text` of `path` was answered: confirmed, when the file still holds it as the client left it. */
|
|
1601
|
+
confirmWritten(path, text) {
|
|
1602
|
+
const disk = this.expected.get(path);
|
|
1603
|
+
if (text === null ? disk === null : disk != null && disk.hash === contentHash(text)) this.confirm(path, text === null ? null : {
|
|
1604
|
+
hash: contentHash(text),
|
|
1605
|
+
text
|
|
1606
|
+
});
|
|
1607
|
+
}
|
|
1133
1608
|
/** A confirmed version of `path`, the newest; the oldest goes beyond HISTORY. */
|
|
1134
1609
|
remember(path, version) {
|
|
1135
1610
|
const list = (this.history.get(path) ?? []).filter((v) => v.text !== version.text);
|
|
@@ -1166,13 +1641,13 @@ var SyncClient = class extends EventEmitter {
|
|
|
1166
1641
|
}
|
|
1167
1642
|
let step;
|
|
1168
1643
|
try {
|
|
1169
|
-
step = await this.whenFree(() =>
|
|
1644
|
+
step = await this.exchangeTurn(() => this.whenFree(() => this.locked(() => this.replaceHeld(path, file, temp, target))));
|
|
1170
1645
|
} catch (error) {
|
|
1171
1646
|
await rm(temp, { force: true });
|
|
1172
1647
|
throw error;
|
|
1173
1648
|
}
|
|
1174
1649
|
if (step.kind === "exists" || step.kind === "missing") return this.schedule(path);
|
|
1175
|
-
if (step.kind === "linked") return;
|
|
1650
|
+
if (step.kind === "linked" || step.kind === "kept") return;
|
|
1176
1651
|
if (step.kind === "removed") {
|
|
1177
1652
|
if (step.moved !== null && step.disk && contentHash(step.moved) !== step.disk.hash) {
|
|
1178
1653
|
const saved = await this.saveConflict(path, step.moved);
|
|
@@ -1191,18 +1666,64 @@ var SyncClient = class extends EventEmitter {
|
|
|
1191
1666
|
const later = swapped && changed && displaced === "";
|
|
1192
1667
|
if (changed && !later) {
|
|
1193
1668
|
this.log(`${path}: a local write landed while the server's version was written; sending it`);
|
|
1194
|
-
await this.
|
|
1669
|
+
await this.queueLocal(path, displaced, { displaced: from });
|
|
1670
|
+
}
|
|
1671
|
+
if (swapped) {
|
|
1672
|
+
const recheck = this.recheck(path, temp, later ? null : displaced, from);
|
|
1673
|
+
this.rechecks.add(recheck);
|
|
1674
|
+
this.track(recheck).finally(() => this.rechecks.delete(recheck));
|
|
1675
|
+
} else await rm(temp, { force: true });
|
|
1676
|
+
}
|
|
1677
|
+
/**
|
|
1678
|
+
* Run `fn`, the part of replace done under the write lock, after the one before it has finished. Exchanges run one
|
|
1679
|
+
* at a time without giving up the thread (the helper is waited for), so many of them at once, as after a large
|
|
1680
|
+
* snapshot, would hold the event loop for seconds: every SWAP_SLICE_MS the event loop gets a turn before the next one,
|
|
1681
|
+
* and the client keeps answering on its port meanwhile.
|
|
1682
|
+
*/
|
|
1683
|
+
async exchangeTurn(fn) {
|
|
1684
|
+
const previous = this.exchanging;
|
|
1685
|
+
let release;
|
|
1686
|
+
this.exchanging = new Promise((resolve) => release = resolve);
|
|
1687
|
+
try {
|
|
1688
|
+
await previous;
|
|
1689
|
+
if (Date.now() - this.sliceStarted >= SWAP_SLICE_MS) {
|
|
1690
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1691
|
+
this.sliceStarted = Date.now();
|
|
1692
|
+
}
|
|
1693
|
+
return await fn();
|
|
1694
|
+
} finally {
|
|
1695
|
+
release();
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
/**
|
|
1699
|
+
* The atomic exchange helper failed or refused to exchange `path`, which is written with a rename: the next file tries
|
|
1700
|
+
* the helper again, so one timeout does not turn layer 3 off. After SWAP_FAILURES in a row, files are written with a
|
|
1701
|
+
* rename for SWAP_PAUSE_MS, since each failure may hold the write lock for the helper's timeout. Recorded as info.
|
|
1702
|
+
*/
|
|
1703
|
+
swapFailed(path, reason) {
|
|
1704
|
+
this.swapFailures++;
|
|
1705
|
+
const pause = this.swapFailures >= SWAP_FAILURES;
|
|
1706
|
+
if (pause) {
|
|
1707
|
+
this.swapPausedUntil = Date.now() + SWAP_PAUSE_MS;
|
|
1708
|
+
this.swapFailures = 0;
|
|
1195
1709
|
}
|
|
1196
|
-
|
|
1197
|
-
|
|
1710
|
+
const message = `atomic exchange failed (${reason}); ${pause ? `failed ${SWAP_FAILURES} times in a row, so files are written with a rename for ${SWAP_PAUSE_MS / 1e3} s` : "the next file tries it again"}`;
|
|
1711
|
+
this.log(`${path}: ${message}`);
|
|
1712
|
+
this.info(path, message);
|
|
1198
1713
|
}
|
|
1199
1714
|
/**
|
|
1200
1715
|
* The part of replace done under the hub's write lock. The outbox is taken in first: every operation in it was
|
|
1201
1716
|
* written on the file as the client expects it before this replacement, and none can be appended until the lock
|
|
1202
1717
|
* is released, so an operation is never compared with a version it was not written on (the invariants at the top).
|
|
1718
|
+
* A file the outbox has a write about keeps it (I4), and a client that is stopping writes nothing. A refused write's
|
|
1719
|
+
* text the file held leaves the outbox in the same hold, once the server's version is there (refuse).
|
|
1203
1720
|
*/
|
|
1204
1721
|
replaceHeld(path, file, temp, target) {
|
|
1205
1722
|
this.editHeld();
|
|
1723
|
+
if (this.closing || this.pending(path)) {
|
|
1724
|
+
rmSync(temp, { force: true });
|
|
1725
|
+
return { kind: "kept" };
|
|
1726
|
+
}
|
|
1206
1727
|
const disk = this.expected.get(path);
|
|
1207
1728
|
const read = (at) => {
|
|
1208
1729
|
try {
|
|
@@ -1218,6 +1739,8 @@ var SyncClient = class extends EventEmitter {
|
|
|
1218
1739
|
if (error.code !== "ENOENT") throw error;
|
|
1219
1740
|
}
|
|
1220
1741
|
this.forget(path);
|
|
1742
|
+
this.confirm(path, null);
|
|
1743
|
+
if (this.holds(path)) this.editHeld((entries) => this.releaseHeld(entries, path));
|
|
1221
1744
|
const moved = read(temp);
|
|
1222
1745
|
rmSync(temp, { force: true });
|
|
1223
1746
|
return {
|
|
@@ -1236,32 +1759,33 @@ var SyncClient = class extends EventEmitter {
|
|
|
1236
1759
|
}
|
|
1237
1760
|
rmSync(temp, { force: true });
|
|
1238
1761
|
this.wrote(path, target);
|
|
1762
|
+
if (this.holds(path)) this.editHeld((entries) => this.releaseHeld(entries, path));
|
|
1239
1763
|
return { kind: "linked" };
|
|
1240
1764
|
}
|
|
1241
1765
|
let displaced = null;
|
|
1242
1766
|
let swapped = false;
|
|
1243
1767
|
let hooked = false;
|
|
1244
|
-
if (this.
|
|
1768
|
+
if (this.swapping) {
|
|
1245
1769
|
this.opts.beforeReplace?.(file);
|
|
1246
1770
|
hooked = true;
|
|
1247
1771
|
let answer;
|
|
1248
1772
|
try {
|
|
1249
1773
|
answer = this.swapper.swap(temp, file);
|
|
1250
1774
|
} catch (error) {
|
|
1251
|
-
|
|
1252
|
-
this.swapper = null;
|
|
1253
|
-
answer = "unsupported";
|
|
1775
|
+
answer = error;
|
|
1254
1776
|
}
|
|
1255
1777
|
if (answer === "missing") {
|
|
1256
1778
|
rmSync(temp, { force: true });
|
|
1257
1779
|
return { kind: "missing" };
|
|
1258
1780
|
}
|
|
1781
|
+
if (answer === "ok") this.swapFailures = 0;
|
|
1782
|
+
else {
|
|
1783
|
+
this.swapFailed(path, answer === "unsupported" ? "the file system refused it" : answer.message);
|
|
1784
|
+
if (answer !== "unsupported" && read(file) === target && read(temp) !== target) answer = "ok";
|
|
1785
|
+
}
|
|
1259
1786
|
if (answer === "ok") {
|
|
1260
1787
|
swapped = true;
|
|
1261
1788
|
displaced = read(temp);
|
|
1262
|
-
} else if (this.swapper) {
|
|
1263
|
-
this.log("the file system refused the atomic exchange; writing with a rename from now on");
|
|
1264
|
-
this.swapper = null;
|
|
1265
1789
|
}
|
|
1266
1790
|
}
|
|
1267
1791
|
if (!swapped) {
|
|
@@ -1271,6 +1795,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
1271
1795
|
}
|
|
1272
1796
|
const among = new Set(this.versions(path).map((v) => v.text));
|
|
1273
1797
|
this.wrote(path, target);
|
|
1798
|
+
if (this.holds(path)) this.editHeld((entries) => this.releaseHeld(entries, path));
|
|
1274
1799
|
return {
|
|
1275
1800
|
kind: "written",
|
|
1276
1801
|
displaced,
|
|
@@ -1297,7 +1822,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
1297
1822
|
...from,
|
|
1298
1823
|
among: /* @__PURE__ */ new Set([...from.among, taken])
|
|
1299
1824
|
};
|
|
1300
|
-
await this.withChain(path, () => this.
|
|
1825
|
+
await this.withChain(path, () => this.queueLocal(path, later, { displaced: held }));
|
|
1301
1826
|
}
|
|
1302
1827
|
}
|
|
1303
1828
|
withChain(path, fn) {
|
|
@@ -1314,16 +1839,19 @@ var SyncClient = class extends EventEmitter {
|
|
|
1314
1839
|
text,
|
|
1315
1840
|
server: text
|
|
1316
1841
|
});
|
|
1317
|
-
this.
|
|
1842
|
+
this.confirm(path, {
|
|
1843
|
+
hash: contentHash(text),
|
|
1844
|
+
text
|
|
1845
|
+
});
|
|
1318
1846
|
}
|
|
1319
1847
|
/**
|
|
1320
1848
|
* Every version a local write of `path` may have started from, oldest first (docs/sync.md "直接改檔的基準"): the
|
|
1321
1849
|
* confirmed ones kept in memory; from the outbox, the base of each queued edit whose base is known, and, unconfirmed,
|
|
1322
|
-
* what each queued write
|
|
1323
|
-
*
|
|
1324
|
-
*
|
|
1325
|
-
*
|
|
1326
|
-
*
|
|
1850
|
+
* what each queued write wrote. A write answered but still queued counts as unconfirmed until it leaves the outbox,
|
|
1851
|
+
* in the same hold that fills in the bases on it. Only the entries before `upto` count, for an entry whose base can
|
|
1852
|
+
* come only from the writes ahead of it. A text both confirmed and unconfirmed is unconfirmed, since only the answer
|
|
1853
|
+
* to the write that put it on disk confirms it; the outbox holds every unconfirmed text, so a restart reads them back.
|
|
1854
|
+
* A text the server refused is no version: it never becomes one the server holds.
|
|
1327
1855
|
*/
|
|
1328
1856
|
versions(path, entries = this.queue, upto = entries.length) {
|
|
1329
1857
|
const out = /* @__PURE__ */ new Map();
|
|
@@ -1334,10 +1862,10 @@ var SyncClient = class extends EventEmitter {
|
|
|
1334
1862
|
if (held && !out.has(held.text)) out.set(held.text, held);
|
|
1335
1863
|
}
|
|
1336
1864
|
for (const entry of ahead) {
|
|
1865
|
+
if (!waiting(entry)) continue;
|
|
1337
1866
|
const text = entry.kind === "op" ? entry.written[path] : entry.kind === "patch" && entry.path === path ? entry.text : void 0;
|
|
1338
1867
|
if (typeof text === "string") out.set(text, { text });
|
|
1339
1868
|
}
|
|
1340
|
-
for (const text of this.unanswered.get(path) ?? []) out.set(text, { text });
|
|
1341
1869
|
return [...out.values()];
|
|
1342
1870
|
}
|
|
1343
1871
|
/**
|
|
@@ -1346,146 +1874,71 @@ var SyncClient = class extends EventEmitter {
|
|
|
1346
1874
|
*/
|
|
1347
1875
|
startOf(path, text, before, scope = {}) {
|
|
1348
1876
|
const all = this.versions(path, scope.entries, scope.upto).filter((v) => scope.among?.has(v.text) ?? true);
|
|
1349
|
-
|
|
1350
|
-
let best;
|
|
1351
|
-
let bestDistance = Infinity;
|
|
1352
|
-
for (const version of candidates) {
|
|
1353
|
-
const distance = lineDistance(version.text, text);
|
|
1354
|
-
if (distance <= bestDistance) {
|
|
1355
|
-
best = version;
|
|
1356
|
-
bestDistance = distance;
|
|
1357
|
-
}
|
|
1358
|
-
}
|
|
1359
|
-
return best;
|
|
1877
|
+
return closestVersion(this.opts.nearestBase === false ? all.filter((v) => v.text === before?.text || v.server === before?.text).slice(-1) : all, text);
|
|
1360
1878
|
}
|
|
1361
1879
|
/**
|
|
1362
|
-
*
|
|
1363
|
-
*
|
|
1364
|
-
*
|
|
1365
|
-
*
|
|
1880
|
+
* Queue a local text of `path`, on the version layer 1 chooses among the versions the writes queued ahead of it
|
|
1881
|
+
* leave. A text on disk is what the client now expects there, adopted in the hold of the write lock that queues it
|
|
1882
|
+
* (I2): a file that no longer holds it there, as after an operation written on it since it was read, or that holds
|
|
1883
|
+
* what the client expects already, is reconciled again instead. A `displaced` text, one the server's version took off
|
|
1884
|
+
* disk, is not on disk: it was written over the version the client had left there, and on a version the old file held
|
|
1885
|
+
* (layer 3); with none of those left, it is based on the text the client had left there, so layer 2 still tells the
|
|
1886
|
+
* writer's lines from the ones the server changed meanwhile. `start` overrides layer 1: the version the writer started
|
|
1887
|
+
* from, or null for a new file.
|
|
1366
1888
|
*/
|
|
1367
|
-
async
|
|
1889
|
+
async queueLocal(path, text, options = {}) {
|
|
1368
1890
|
const { displaced } = options;
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1891
|
+
if (await this.enqueue((entries) => {
|
|
1892
|
+
const before = displaced ? displaced.over : this.expected.get(path);
|
|
1893
|
+
if (!displaced && (!this.onDisk(path, text) || before?.hash === contentHash(text))) return null;
|
|
1894
|
+
const last = before?.text;
|
|
1895
|
+
if (!displaced) this.expected.set(path, {
|
|
1373
1896
|
hash: contentHash(text),
|
|
1374
1897
|
text
|
|
1375
1898
|
});
|
|
1376
|
-
this.
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
this.trace(`local ${path} ${short(text)} on disk ${short(before?.text ?? null)} versions [${this.versions(path).map((v) => `${short(v.text)}→${v.server === void 0 ? "?" : short(v.server)}`).join(" ")}] chose base ${write.base?.slice(0, 7) ?? "null"} baseText ${short(write.baseText ?? null)}${write.serverBase === void 0 ? "" : ` serverBase ${short(write.serverBase)}`}`);
|
|
1380
|
-
if (!this.caughtUp) return this.enqueue(write);
|
|
1381
|
-
const request = patchRequest(write);
|
|
1382
|
-
if (request === "unresolved") return this.enqueue(write);
|
|
1383
|
-
if (request === "overlap") {
|
|
1384
|
-
const saved = await this.saveConflict(path, text);
|
|
1385
|
-
return this.problem({
|
|
1386
|
-
kind: "conflict",
|
|
1387
|
-
path,
|
|
1388
|
-
saved,
|
|
1389
|
-
message: `${path} was edited on top of an earlier edit that the server merged with other changes, and the two edits overlap; the text written here was saved to ${saved}`
|
|
1899
|
+
let start = options.start !== void 0 ? options.start ?? void 0 : before === null ? void 0 : this.startOf(path, text, before, {
|
|
1900
|
+
entries,
|
|
1901
|
+
among: displaced?.among
|
|
1390
1902
|
});
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
*/
|
|
1404
|
-
async sendEdit(write, request, context) {
|
|
1405
|
-
const { path } = write;
|
|
1406
|
-
this.inflight.set(path, (this.inflight.get(path) ?? 0) + 1);
|
|
1407
|
-
this.awaitAnswer(path, write.text);
|
|
1408
|
-
const id = entryId();
|
|
1409
|
-
const firstSentSeq = this.hubSeq;
|
|
1410
|
-
const queued = this.queuedIds();
|
|
1411
|
-
this.trace(`patch ${path} ${id} base ${request.base?.slice(0, 7) ?? "null"} text ${short(request.text)} writer ${short(context.writer)} baseText ${short(context.base ?? null)}`);
|
|
1412
|
-
try {
|
|
1413
|
-
let response;
|
|
1414
|
-
try {
|
|
1415
|
-
response = await this.api.send({
|
|
1416
|
-
...request,
|
|
1417
|
-
requestId: id
|
|
1418
|
-
});
|
|
1419
|
-
} catch (error) {
|
|
1420
|
-
this.trace(`patch ${path} failed: ${error.message}`);
|
|
1421
|
-
if (!(error instanceof Unreachable)) throw error;
|
|
1422
|
-
return await this.enqueue(write, {
|
|
1423
|
-
queued,
|
|
1424
|
-
...error.sent ? { sent: {
|
|
1425
|
-
id,
|
|
1426
|
-
firstSentSeq,
|
|
1427
|
-
sent: request
|
|
1428
|
-
} } : {}
|
|
1429
|
-
});
|
|
1430
|
-
}
|
|
1431
|
-
await this.settleEdit(path, response, context, request.text);
|
|
1432
|
-
} finally {
|
|
1433
|
-
this.answeredDirect(path, write.text);
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1436
|
-
/** The outbox entries as last read, for a write sent outside it: what was queued when it was sent. */
|
|
1437
|
-
queuedIds() {
|
|
1438
|
-
return new Set(this.queue.map((entry) => entry.id));
|
|
1439
|
-
}
|
|
1440
|
-
/** A direct edit or Edit sent outside the outbox now waits for its answer. */
|
|
1441
|
-
awaitAnswer(path, text) {
|
|
1442
|
-
this.unanswered.set(path, [...this.unanswered.get(path) ?? [], text]);
|
|
1443
|
-
}
|
|
1444
|
-
/**
|
|
1445
|
-
* A direct edit or Edit sent outside the outbox was answered, or queued: its text is confirmed, or held by the
|
|
1446
|
-
* outbox. The outbox is read again, which fills in the base of the edits queued on it, and sent.
|
|
1447
|
-
*/
|
|
1448
|
-
answeredDirect(path, text) {
|
|
1449
|
-
const texts = this.unanswered.get(path) ?? [];
|
|
1450
|
-
texts.splice(texts.indexOf(text), 1);
|
|
1451
|
-
if (texts.length === 0) this.unanswered.delete(path);
|
|
1452
|
-
this.release(path);
|
|
1453
|
-
if (this.queue.length > 0) this.outboxChanged();
|
|
1454
|
-
}
|
|
1455
|
-
release(path) {
|
|
1456
|
-
const count = (this.inflight.get(path) ?? 1) - 1;
|
|
1457
|
-
if (count > 0) this.inflight.set(path, count);
|
|
1458
|
-
else this.inflight.delete(path);
|
|
1459
|
-
this.dirty.delete(path);
|
|
1903
|
+
if (displaced && start === void 0 && displaced.over.text !== void 0) start = {
|
|
1904
|
+
text: displaced.over.text,
|
|
1905
|
+
server: displaced.over.text
|
|
1906
|
+
};
|
|
1907
|
+
const write = {
|
|
1908
|
+
...editOn(path, text, start, options.start === null ? null : before?.hash ?? null, last),
|
|
1909
|
+
...displaced ? { displaced: true } : {}
|
|
1910
|
+
};
|
|
1911
|
+
this.trace(`local ${path} ${short(text)} on disk ${short(before?.text ?? null)} versions [${this.versions(path, entries).map((v) => `${short(v.text)}→${v.server === void 0 ? "?" : short(v.server)}`).join(" ")}] chose base ${write.base?.slice(0, 7) ?? "null"} baseText ${short(write.baseText ?? null)}${write.serverBase === void 0 ? "" : ` serverBase ${short(write.serverBase)}`}`);
|
|
1912
|
+
return write;
|
|
1913
|
+
}) !== null) return;
|
|
1914
|
+
this.trace(`local ${path} ${short(text)} changed before it was queued; reconciling it again`);
|
|
1460
1915
|
this.schedule(path);
|
|
1461
1916
|
}
|
|
1462
|
-
/** What
|
|
1463
|
-
async settleEdit(
|
|
1917
|
+
/** What the response to queued edit `entry` means for the writer (layer 2), then wait for the broadcast it caused. */
|
|
1918
|
+
async settleEdit(entry, response, context, sent) {
|
|
1919
|
+
const { path } = entry;
|
|
1464
1920
|
this.trace(`response ${path} ${response.ok ? `${response.files.map((f) => `${f.path} ${f.kind === "file" ? short(f.text) : f.kind}`).join(", ")}` : `${response.reason}: ${response.message}`}`);
|
|
1465
1921
|
if (response.ok) {
|
|
1466
1922
|
const file = response.files.find((f) => f.path === path);
|
|
1467
1923
|
const result = file?.kind === "file" ? file.text : file?.kind === "removed" ? null : this.cloud.get(path) ?? sent ?? context.writer;
|
|
1468
|
-
if (typeof result === "string")
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1924
|
+
if (typeof result === "string") {
|
|
1925
|
+
this.remember(path, {
|
|
1926
|
+
text: context.writer,
|
|
1927
|
+
server: result
|
|
1928
|
+
});
|
|
1929
|
+
this.confirmWritten(path, context.writer);
|
|
1930
|
+
this.classify(path, context, result);
|
|
1931
|
+
}
|
|
1473
1932
|
if (file) await this.delivery(path, response.seq);
|
|
1474
1933
|
return;
|
|
1475
1934
|
}
|
|
1476
|
-
const
|
|
1477
|
-
|
|
1478
|
-
kind: "conflict",
|
|
1935
|
+
const conflict = response.reason === "conflict";
|
|
1936
|
+
await this.refuse(entry, [{
|
|
1937
|
+
kind: conflict ? "conflict" : "refused",
|
|
1479
1938
|
path,
|
|
1480
|
-
|
|
1481
|
-
message: `${path}: the server refused the change (${response.message}); the text written here was saved to ${saved} and the file now has the server's version`
|
|
1482
|
-
});
|
|
1483
|
-
else this.problem({
|
|
1484
|
-
kind: "refused",
|
|
1485
|
-
path,
|
|
1486
|
-
saved,
|
|
1487
|
-
message: `${path}: the server refused the change (${response.message}); the text written here was saved to ${saved}`
|
|
1488
|
-
});
|
|
1939
|
+
text: context.writer,
|
|
1940
|
+
message: (saved) => `${path}: the server refused the change (${response.message}); the text written here was saved to ${saved}${conflict ? " and the file now has the server's version" : ""}`
|
|
1941
|
+
}]);
|
|
1489
1942
|
if ("files" in response && response.files.some((f) => f.path === path)) await this.delivery(path, response.seq);
|
|
1490
1943
|
}
|
|
1491
1944
|
/**
|
|
@@ -1537,60 +1990,33 @@ var SyncClient = class extends EventEmitter {
|
|
|
1537
1990
|
}
|
|
1538
1991
|
this.info(path, "merged with changes from other environments");
|
|
1539
1992
|
}
|
|
1540
|
-
/**
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
const request = {
|
|
1551
|
-
protocol: 2,
|
|
1552
|
-
op: "file.remove",
|
|
1553
|
-
input: {
|
|
1554
|
-
path,
|
|
1555
|
-
base
|
|
1556
|
-
},
|
|
1557
|
-
now: requestTime()
|
|
1558
|
-
};
|
|
1559
|
-
const queued = this.queuedIds();
|
|
1560
|
-
this.inflight.set(path, (this.inflight.get(path) ?? 0) + 1);
|
|
1561
|
-
try {
|
|
1562
|
-
let unsent = false;
|
|
1563
|
-
const response = await this.api.send({
|
|
1564
|
-
...request,
|
|
1565
|
-
requestId: id
|
|
1566
|
-
}).catch((error) => {
|
|
1567
|
-
if (!(error instanceof Unreachable)) throw error;
|
|
1568
|
-
unsent = !error.sent;
|
|
1569
|
-
return null;
|
|
1570
|
-
});
|
|
1571
|
-
if (response === null) return this.enqueue({
|
|
1993
|
+
/**
|
|
1994
|
+
* Queue the deletion of `path`, which held `seen` as the client left it: its hash is the deletion's base. Its versions
|
|
1995
|
+
* are forgotten in the hold of the write lock that queues it (I2), while the file is still gone there and the client
|
|
1996
|
+
* still expects `seen`; otherwise, as after an operation that wrote the file since it was read, it is reconciled again.
|
|
1997
|
+
*/
|
|
1998
|
+
async queueRemove(path, seen) {
|
|
1999
|
+
if (await this.enqueue(() => {
|
|
2000
|
+
if (this.expected.get(path)?.hash !== seen.hash || !this.onDisk(path, null)) return null;
|
|
2001
|
+
this.forget(path);
|
|
2002
|
+
return {
|
|
1572
2003
|
kind: "remove",
|
|
1573
2004
|
path,
|
|
1574
|
-
base
|
|
1575
|
-
}
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
firstSentSeq,
|
|
1580
|
-
sent: request
|
|
1581
|
-
} }
|
|
1582
|
-
});
|
|
1583
|
-
await this.settleRemove(path, response);
|
|
1584
|
-
} finally {
|
|
1585
|
-
this.release(path);
|
|
1586
|
-
}
|
|
2005
|
+
base: seen.hash
|
|
2006
|
+
};
|
|
2007
|
+
}) !== null) return;
|
|
2008
|
+
this.trace(`removal of ${path} changed before it was queued; reconciling it again`);
|
|
2009
|
+
this.schedule(path);
|
|
1587
2010
|
}
|
|
1588
|
-
async settleRemove(
|
|
1589
|
-
|
|
2011
|
+
async settleRemove(entry, response) {
|
|
2012
|
+
const { path } = entry;
|
|
2013
|
+
if (!response.ok) return this.refuse(entry, [{
|
|
1590
2014
|
kind: "refused",
|
|
1591
2015
|
path,
|
|
1592
|
-
|
|
1593
|
-
|
|
2016
|
+
text: null,
|
|
2017
|
+
message: () => `${path}: the server refused the deletion (${response.message})`
|
|
2018
|
+
}]);
|
|
2019
|
+
if (response.files.some((f) => f.path === path && f.kind === "removed")) this.confirmWritten(path, null);
|
|
1594
2020
|
for (const notice of response.notices) this.problem({
|
|
1595
2021
|
kind: "removal-kept",
|
|
1596
2022
|
path: notice.path,
|
|
@@ -1599,19 +2025,39 @@ var SyncClient = class extends EventEmitter {
|
|
|
1599
2025
|
if (response.files.some((f) => f.path === path)) await this.delivery(path, response.seq);
|
|
1600
2026
|
}
|
|
1601
2027
|
/**
|
|
1602
|
-
* What an outbox entry left on disk is what the client expects there
|
|
1603
|
-
* outbox is where versions() reads them.
|
|
2028
|
+
* What an outbox entry left on disk is what the client expects there; a displaced text left nothing there, since the
|
|
2029
|
+
* server's version took it off. The versions it holds need nothing more: the outbox is where versions() reads them.
|
|
1604
2030
|
*/
|
|
1605
2031
|
restoreEntry(entry) {
|
|
2032
|
+
if (entry.refused) {
|
|
2033
|
+
for (const path of entry.refused.paths) {
|
|
2034
|
+
const text = wroteAt(entry, path);
|
|
2035
|
+
if (text !== void 0) this.expected.set(path, text === null ? null : {
|
|
2036
|
+
hash: contentHash(text),
|
|
2037
|
+
text
|
|
2038
|
+
});
|
|
2039
|
+
}
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
1606
2042
|
if (entry.kind === "op") for (const [path, text] of Object.entries(entry.written)) this.expected.set(path, text === null ? null : {
|
|
1607
2043
|
hash: contentHash(text),
|
|
1608
2044
|
text
|
|
1609
2045
|
});
|
|
1610
|
-
else if (entry.kind === "patch")
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
2046
|
+
else if (entry.kind === "patch") {
|
|
2047
|
+
if (!entry.displaced) this.expected.set(entry.path, {
|
|
2048
|
+
hash: contentHash(entry.text),
|
|
2049
|
+
text: entry.text
|
|
2050
|
+
});
|
|
2051
|
+
} else this.expected.set(entry.path, null);
|
|
2052
|
+
}
|
|
2053
|
+
/**
|
|
2054
|
+
* Hold the write lock for `fn`; work stop() abandoned gets an error ('abandoned after stop') instead, so it leaves
|
|
2055
|
+
* the outbox, known.json and the synced files alone. It can still add files under local/sync/ that no run reads as
|
|
2056
|
+
* state: saved texts in conflicts/, lines in notices.jsonl and temporary files in tmp/.
|
|
2057
|
+
*/
|
|
2058
|
+
locked(fn) {
|
|
2059
|
+
if (this.abandoned) throw new Error("abandoned after stop");
|
|
2060
|
+
return underLock(this.root, fn);
|
|
1615
2061
|
}
|
|
1616
2062
|
/**
|
|
1617
2063
|
* Change the outbox: read queue.json, take in the entries other writers appended since it was last read, let `edit`
|
|
@@ -1622,13 +2068,14 @@ var SyncClient = class extends EventEmitter {
|
|
|
1622
2068
|
* (resolveBases), before `edit` and after it, so the entry that confirmed it leaves the outbox in the same hold.
|
|
1623
2069
|
*/
|
|
1624
2070
|
editQueue(edit) {
|
|
1625
|
-
return
|
|
2071
|
+
return this.locked(() => this.editHeld(edit));
|
|
1626
2072
|
}
|
|
1627
2073
|
/** editQueue for a caller that holds the write lock already. */
|
|
1628
2074
|
editHeld(edit) {
|
|
1629
2075
|
const entries = readQueue(this.root);
|
|
1630
2076
|
const known = new Set(this.queue.map((entry) => entry.id));
|
|
1631
2077
|
let changed = false;
|
|
2078
|
+
const fresh = [];
|
|
1632
2079
|
for (let i = 0; i < entries.length; i++) {
|
|
1633
2080
|
const entry = entries[i];
|
|
1634
2081
|
if (known.has(entry.id)) continue;
|
|
@@ -1640,7 +2087,9 @@ var SyncClient = class extends EventEmitter {
|
|
|
1640
2087
|
changed = true;
|
|
1641
2088
|
}
|
|
1642
2089
|
this.restoreEntry(entry);
|
|
2090
|
+
if (waiting(entry)) fresh.push(entry);
|
|
1643
2091
|
}
|
|
2092
|
+
for (const entry of fresh) for (const path of entryPaths(entry)) if (this.releaseHeld(entries, path, entries.indexOf(entry))) changed = true;
|
|
1644
2093
|
if (this.resolveBases(entries)) changed = true;
|
|
1645
2094
|
if (edit && edit(entries) !== false) {
|
|
1646
2095
|
changed = true;
|
|
@@ -1753,7 +2202,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
1753
2202
|
resolveBases(entries) {
|
|
1754
2203
|
let changed = false;
|
|
1755
2204
|
entries.forEach((entry, at) => {
|
|
1756
|
-
if (entry.kind !== "patch" || entry.base !== null || entry.baseText === void 0 || entry.sent !== void 0) return;
|
|
2205
|
+
if (entry.kind !== "patch" || entry.base !== null || entry.baseText === void 0 || entry.sent !== void 0 || !waiting(entry)) return;
|
|
1757
2206
|
const start = this.versions(entry.path, entries, at).find((v) => v.text === entry.baseText);
|
|
1758
2207
|
if (start?.server === void 0) return;
|
|
1759
2208
|
Object.assign(entry, editOn(entry.path, entry.text, start, null));
|
|
@@ -1778,34 +2227,42 @@ var SyncClient = class extends EventEmitter {
|
|
|
1778
2227
|
await sleep$1(500);
|
|
1779
2228
|
}
|
|
1780
2229
|
}
|
|
1781
|
-
/**
|
|
2230
|
+
/**
|
|
2231
|
+
* queue.json changed, or may have: take in what other writers appended and send it. A writer that does not wait for
|
|
2232
|
+
* the server calls it over the port right after appending (notifyQueued); the watcher of queue.json and the rescan
|
|
2233
|
+
* find an append no writer told of.
|
|
2234
|
+
*/
|
|
1782
2235
|
outboxChanged() {
|
|
1783
|
-
|
|
2236
|
+
if (this.closing) return;
|
|
2237
|
+
const before = this.queued;
|
|
1784
2238
|
this.track(this.whenFree(() => this.syncQueue()).then(() => {
|
|
1785
|
-
if (this.
|
|
1786
|
-
if (this.online && this.
|
|
2239
|
+
if (this.queued !== before) this.log(`outbox: ${this.queued} waiting`);
|
|
2240
|
+
if (this.online && this.queued > 0) this.track(this.flushQueue());
|
|
1787
2241
|
}).catch((error) => this.log(`outbox: ${error.message}`)));
|
|
1788
2242
|
}
|
|
1789
2243
|
/**
|
|
1790
|
-
* Queue a write, as an entry of its own
|
|
1791
|
-
*
|
|
1792
|
-
*
|
|
1793
|
-
*
|
|
1794
|
-
*
|
|
2244
|
+
* Queue a write after every write queued already, as an entry of its own, in one hold of the write lock: `make` runs
|
|
2245
|
+
* once the outbox is taken in, with the outbox as read, and returns the write, or null to queue nothing. What `make`
|
|
2246
|
+
* adopts from disk is adopted in the same hold, so an operation appended since the file was read is taken in first and
|
|
2247
|
+
* compared with what the client expected before (I2). An edit keeps the base layer 1 chose for it (docs/sync.md
|
|
2248
|
+
* "直接改檔的基準"). Returns the entry's id, or null when nothing was queued.
|
|
1795
2249
|
*/
|
|
1796
|
-
async enqueue(
|
|
2250
|
+
async enqueue(make) {
|
|
2251
|
+
const id = entryId();
|
|
2252
|
+
let write = null;
|
|
1797
2253
|
await this.whenFree(() => this.editQueue((entries) => {
|
|
1798
|
-
|
|
2254
|
+
write = make(entries);
|
|
2255
|
+
if (!write) return false;
|
|
2256
|
+
entries.push({
|
|
1799
2257
|
...write,
|
|
1800
|
-
id
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
const at = from ? entries.findIndex((queued) => !from.queued.has(queued.id)) : -1;
|
|
1804
|
-
if (at < 0) entries.push(entry);
|
|
1805
|
-
else entries.splice(at, 0, entry);
|
|
2258
|
+
id
|
|
2259
|
+
});
|
|
2260
|
+
for (const path of entryPaths(write)) this.releaseHeld(entries, path, entries.length - 1);
|
|
1806
2261
|
}));
|
|
2262
|
+
if (!write) return null;
|
|
1807
2263
|
this.log(`queued ${write.kind} ${entryPaths(write).join(", ")} (${this.queue.length} waiting)`);
|
|
1808
2264
|
if (this.online) this.track(this.flushQueue());
|
|
2265
|
+
return id;
|
|
1809
2266
|
}
|
|
1810
2267
|
/**
|
|
1811
2268
|
* Send the outbox in order, one at a time, each after the previous one's response. An answered entry leaves the
|
|
@@ -1814,25 +2271,27 @@ var SyncClient = class extends EventEmitter {
|
|
|
1814
2271
|
* sends and its firstSentSeq are written to the outbox (`sent`), so it is sent again as it was even after a restart,
|
|
1815
2272
|
* and nothing is merged into it. An edit whose base cannot be moved onto the server's version is not sent.
|
|
1816
2273
|
*/
|
|
1817
|
-
|
|
1818
|
-
if (this.flushing) return;
|
|
2274
|
+
flushQueue() {
|
|
2275
|
+
if (this.flushing || this.closing) return Promise.resolve();
|
|
2276
|
+
const run = this.sendOutbox();
|
|
2277
|
+
this.flushRun = run;
|
|
2278
|
+
return run.finally(() => {
|
|
2279
|
+
if (this.flushRun === run) this.flushRun = null;
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2282
|
+
async sendOutbox() {
|
|
1819
2283
|
this.flushing = true;
|
|
1820
2284
|
try {
|
|
1821
2285
|
while (this.caughtUp) {
|
|
1822
2286
|
let entry;
|
|
1823
2287
|
let again = false;
|
|
1824
2288
|
let unmergeable = false;
|
|
1825
|
-
let waiting = false;
|
|
1826
2289
|
this.editQueue((entries) => {
|
|
1827
|
-
entry = entries
|
|
2290
|
+
entry = entries.find((queued) => waiting(queued) || this.answered.has(queued.id));
|
|
1828
2291
|
if (!entry || this.answered.has(entry.id)) return false;
|
|
1829
2292
|
again = entry.firstSentSeq !== void 0;
|
|
1830
2293
|
if (entry.sent) return false;
|
|
1831
2294
|
const request = this.prepare(entry);
|
|
1832
|
-
if (request === "wait") {
|
|
1833
|
-
waiting = true;
|
|
1834
|
-
return false;
|
|
1835
|
-
}
|
|
1836
2295
|
if (!request) {
|
|
1837
2296
|
unmergeable = true;
|
|
1838
2297
|
return false;
|
|
@@ -1840,7 +2299,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
1840
2299
|
entry.sent = request;
|
|
1841
2300
|
entry.firstSentSeq ??= this.hubSeq;
|
|
1842
2301
|
});
|
|
1843
|
-
if (!entry ||
|
|
2302
|
+
if (!entry || this.closing) break;
|
|
1844
2303
|
if (unmergeable) await this.refuseUnmergeable(entry);
|
|
1845
2304
|
else if (!this.answered.has(entry.id)) {
|
|
1846
2305
|
this.sending = entry.id;
|
|
@@ -1857,18 +2316,20 @@ var SyncClient = class extends EventEmitter {
|
|
|
1857
2316
|
}
|
|
1858
2317
|
retryQueue() {
|
|
1859
2318
|
clearTimeout(this.queueTimer);
|
|
1860
|
-
if (!this.stopped) this.queueTimer = setTimeout(() => void this.track(this.flushQueue()), 1e3 + Math.random() * 1e3);
|
|
2319
|
+
if (!this.stopped && !this.closing) this.queueTimer = setTimeout(() => void this.track(this.flushQueue()), 1e3 + Math.random() * 1e3);
|
|
1861
2320
|
}
|
|
1862
2321
|
/**
|
|
1863
2322
|
* Take an answered entry out of the outbox, and give the ids the server renumbered to the files written here and
|
|
1864
|
-
* to the entries after it, in one hold of the write lock (docs/sync-protocol.md).
|
|
2323
|
+
* to the entries after it, in one hold of the write lock (docs/sync-protocol.md). A refused one stays while a file
|
|
2324
|
+
* holds what it wrote, until the server's version is there (refuse). Throws while the lock is taken.
|
|
1865
2325
|
*/
|
|
1866
2326
|
finish(entry) {
|
|
1867
2327
|
const renumbered = this.renumbering.get(entry.id);
|
|
1868
2328
|
this.editQueue((entries) => {
|
|
1869
2329
|
if (renumbered) this.renumber(entries, entry, renumbered);
|
|
1870
2330
|
const at = entries.findIndex((e) => e.id === entry.id);
|
|
1871
|
-
if (at >= 0) entries.splice(at, 1);
|
|
2331
|
+
if (at >= 0 && waiting(entries[at])) entries.splice(at, 1);
|
|
2332
|
+
else this.dropSettled(entries);
|
|
1872
2333
|
});
|
|
1873
2334
|
this.answered.delete(entry.id);
|
|
1874
2335
|
this.renumbering.delete(entry.id);
|
|
@@ -1920,9 +2381,11 @@ var SyncClient = class extends EventEmitter {
|
|
|
1920
2381
|
* stays queued. A connection that broke after sending needs no warning: the request goes again under the same id,
|
|
1921
2382
|
* and a server that took it answers with the response it kept. `again` says it may have been sent before, so it
|
|
1922
2383
|
* carries firstSentSeq; when the server no longer keeps the responses that far back and cannot tell whether it
|
|
1923
|
-
* applied it, the writer is told and it is sent once more as a new request.
|
|
2384
|
+
* applied it, the writer is told and it is sent once more as a new request. A connection that closed under the
|
|
2385
|
+
* request (`broken`), as a kept one the server closed while idle does, is not waited out: the request goes again at
|
|
2386
|
+
* once, on a connection of its own (`fresh`), as sent again. Once only: a second failure leaves it to the retry.
|
|
1924
2387
|
*/
|
|
1925
|
-
async request(entry, request, again) {
|
|
2388
|
+
async request(entry, request, again, fresh = false) {
|
|
1926
2389
|
const firstSentSeq = again ? entry.firstSentSeq : void 0;
|
|
1927
2390
|
this.trace(`send ${request.op} ${entry.id}${firstSentSeq === void 0 ? "" : ` again, first sent at seq ${firstSentSeq}`}`);
|
|
1928
2391
|
let response;
|
|
@@ -1934,10 +2397,11 @@ var SyncClient = class extends EventEmitter {
|
|
|
1934
2397
|
...request,
|
|
1935
2398
|
requestId: entry.id,
|
|
1936
2399
|
firstSentSeq
|
|
1937
|
-
});
|
|
2400
|
+
}, { fresh });
|
|
1938
2401
|
} catch (error) {
|
|
1939
2402
|
if (!(error instanceof Unreachable)) throw error;
|
|
1940
2403
|
this.trace(`send ${entry.id} failed: ${error.message}`);
|
|
2404
|
+
if (error.broken && !fresh && !this.closing) return this.request(entry, request, true, true);
|
|
1941
2405
|
return null;
|
|
1942
2406
|
}
|
|
1943
2407
|
if (!response.ok && response.reason === "uncertain") {
|
|
@@ -1947,15 +2411,16 @@ var SyncClient = class extends EventEmitter {
|
|
|
1947
2411
|
path: paths[0] ?? "",
|
|
1948
2412
|
message: `${request.op} of ${paths.join(", ")} was sent before without an answer, and the sync server no longer keeps its answers from then (${response.message}); it is sent again as a new request and may apply twice, so check the result for a duplicate`
|
|
1949
2413
|
});
|
|
1950
|
-
return this.request(entry, request, false);
|
|
2414
|
+
return this.request(entry, request, false, fresh);
|
|
1951
2415
|
}
|
|
1952
2416
|
return response;
|
|
1953
2417
|
}
|
|
1954
2418
|
/**
|
|
1955
2419
|
* The request an outbox entry sends, made when it is first sent, from the entry alone: an operation on the current
|
|
1956
|
-
* protocol, a deletion with the time it is first sent, an
|
|
1957
|
-
*
|
|
2420
|
+
* protocol, a deletion with the time it is first sent, an Edit's replacement to be made again on the server's latest
|
|
2421
|
+
* version, an edit as patchRequest makes it. Null for an edit that cannot be sent: its base is a write the server
|
|
1958
2422
|
* refused or did not take as it was, so its base never became known, or moving it onto the server's merge overlaps.
|
|
2423
|
+
* Every write ahead of it was answered, so a base still unknown never will be.
|
|
1959
2424
|
*/
|
|
1960
2425
|
prepare(entry) {
|
|
1961
2426
|
if (entry.kind === "op") return currentRequest(entry.request);
|
|
@@ -1968,34 +2433,139 @@ var SyncClient = class extends EventEmitter {
|
|
|
1968
2433
|
},
|
|
1969
2434
|
now: requestTime()
|
|
1970
2435
|
};
|
|
2436
|
+
if (entry.replace) return {
|
|
2437
|
+
protocol: 2,
|
|
2438
|
+
op: "text.replace",
|
|
2439
|
+
path: entry.path,
|
|
2440
|
+
old: entry.replace.old,
|
|
2441
|
+
new: entry.replace.new
|
|
2442
|
+
};
|
|
1971
2443
|
const request = patchRequest(entry);
|
|
1972
|
-
|
|
1973
|
-
return request === "unresolved" && this.unanswered.get(entry.path)?.includes(entry.baseText) ? "wait" : null;
|
|
2444
|
+
return typeof request === "object" ? request : null;
|
|
1974
2445
|
}
|
|
1975
2446
|
/** An edit prepare cannot send: its text is saved and the writer told; it is not sent. */
|
|
1976
2447
|
async refuseUnmergeable(entry) {
|
|
1977
|
-
const saved = await this.saveConflict(entry.path, entry.text);
|
|
1978
2448
|
this.answer(entry);
|
|
1979
|
-
this.
|
|
2449
|
+
await this.refuse(entry, [{
|
|
1980
2450
|
kind: "conflict",
|
|
1981
2451
|
path: entry.path,
|
|
1982
|
-
|
|
1983
|
-
message: `${entry.path} was edited on top of a write the server did not take as it was, or the edit overlaps what the server merged into it; the text written here was saved to ${saved}`
|
|
2452
|
+
text: entry.text,
|
|
2453
|
+
message: (saved) => `${entry.path} was edited on top of a write the server did not take as it was, or the edit overlaps what the server merged into it; the text written here was saved to ${saved}`
|
|
2454
|
+
}]);
|
|
2455
|
+
}
|
|
2456
|
+
/**
|
|
2457
|
+
* The server refused `entry`, or it cannot be sent: the writer is told of each of `told` and its text saved, once,
|
|
2458
|
+
* wherever the sync client is killed (src/state.ts). First, in one hold of the write lock, the refusal is written on
|
|
2459
|
+
* the entry: what the writer is told, with the name each text is saved under, and the files that hold what the entry
|
|
2460
|
+
* wrote there as the client left it. Then each text is saved and the writer told. The entry is sent no more; it leaves
|
|
2461
|
+
* the outbox once no file holds what it wrote (releaseHeld), so a restart before the server's version is on disk puts
|
|
2462
|
+
* that version back rather than send the refused text again. An operation's writer is answered with `outcome`.
|
|
2463
|
+
*/
|
|
2464
|
+
async refuse(entry, told, outcome) {
|
|
2465
|
+
const problems = told.map(({ kind, path, text, message }) => {
|
|
2466
|
+
const saved = text === null ? void 0 : this.conflictName(path);
|
|
2467
|
+
return {
|
|
2468
|
+
kind,
|
|
2469
|
+
path,
|
|
2470
|
+
message: message(saved),
|
|
2471
|
+
...saved === void 0 ? {} : { saved }
|
|
2472
|
+
};
|
|
1984
2473
|
});
|
|
2474
|
+
let marked;
|
|
2475
|
+
await this.whenFree(() => this.editQueue((entries) => {
|
|
2476
|
+
marked = entries.find((queued) => queued.id === entry.id);
|
|
2477
|
+
if (!marked) return false;
|
|
2478
|
+
marked.refused = {
|
|
2479
|
+
paths: entryPaths(marked).filter((path) => this.leftHere(marked, path)),
|
|
2480
|
+
told: problems,
|
|
2481
|
+
...outcome ? { outcome } : {}
|
|
2482
|
+
};
|
|
2483
|
+
}));
|
|
2484
|
+
this.trace(`refused ${entry.id} holding [${marked?.refused?.paths.join(", ") ?? ""}]`);
|
|
2485
|
+
await this.tell(marked ?? {
|
|
2486
|
+
...entry,
|
|
2487
|
+
refused: {
|
|
2488
|
+
paths: [],
|
|
2489
|
+
told: problems,
|
|
2490
|
+
...outcome ? { outcome } : {}
|
|
2491
|
+
}
|
|
2492
|
+
}, () => false);
|
|
2493
|
+
}
|
|
2494
|
+
/**
|
|
2495
|
+
* Save each text of refused `entry` not saved yet, tell the writer of what `before` says it was not told of, and answer
|
|
2496
|
+
* an operation's writer; the entry may leave the outbox from now on (dropSettled). A text that cannot be saved is
|
|
2497
|
+
* still told of.
|
|
2498
|
+
*/
|
|
2499
|
+
async tell(entry, before) {
|
|
2500
|
+
const refusal = entry.refused;
|
|
2501
|
+
for (const problem of refusal.told) {
|
|
2502
|
+
if (before(problem)) continue;
|
|
2503
|
+
const text = textAt(entry, problem.path);
|
|
2504
|
+
if (problem.saved !== void 0 && typeof text === "string") await this.saveText(problem.saved, text).catch((error) => this.log(`${problem.path}: could not save ${problem.saved}: ${String(error)}`));
|
|
2505
|
+
this.problem(problem, entry.id);
|
|
2506
|
+
}
|
|
2507
|
+
if (refusal.outcome) this.settled(entry.id, {
|
|
2508
|
+
state: "refused",
|
|
2509
|
+
...refusal.outcome,
|
|
2510
|
+
saved: refusal.told.flatMap((problem) => problem.saved ?? [])
|
|
2511
|
+
});
|
|
2512
|
+
this.told.add(entry.id);
|
|
2513
|
+
}
|
|
2514
|
+
/** Whether `path` holds what `entry` wrote there, as the client left it. */
|
|
2515
|
+
leftHere(entry, path) {
|
|
2516
|
+
const text = wroteAt(entry, path);
|
|
2517
|
+
const disk = this.expected.get(path);
|
|
2518
|
+
if (text === void 0 || disk === void 0) return false;
|
|
2519
|
+
return text === null ? disk === null : disk !== null && disk.hash === contentHash(text);
|
|
2520
|
+
}
|
|
2521
|
+
/**
|
|
2522
|
+
* `path` holds a refused write's text no more as the client left it: the server's version is there, or another write,
|
|
2523
|
+
* or a text the client took. Each refused entry before `before` lets it go, and one whose writer was told that no file
|
|
2524
|
+
* holds leaves the outbox. Called under the write lock with the outbox as read; true when it changed it.
|
|
2525
|
+
*/
|
|
2526
|
+
releaseHeld(entries, path, before = entries.length) {
|
|
2527
|
+
let changed = false;
|
|
2528
|
+
for (const entry of entries.slice(0, before)) {
|
|
2529
|
+
if (!entry.refused?.paths.includes(path)) continue;
|
|
2530
|
+
entry.refused.paths = entry.refused.paths.filter((held) => held !== path);
|
|
2531
|
+
changed = true;
|
|
2532
|
+
}
|
|
2533
|
+
return this.dropSettled(entries) || changed;
|
|
2534
|
+
}
|
|
2535
|
+
/** Take out of the outbox each refused entry no file holds whose writer was told; true when it took one out. */
|
|
2536
|
+
dropSettled(entries) {
|
|
2537
|
+
let changed = false;
|
|
2538
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
2539
|
+
const entry = entries[i];
|
|
2540
|
+
if (entry.refused === void 0 || entry.refused.paths.length > 0 || !this.told.has(entry.id)) continue;
|
|
2541
|
+
entries.splice(i, 1);
|
|
2542
|
+
this.told.delete(entry.id);
|
|
2543
|
+
changed = true;
|
|
2544
|
+
}
|
|
2545
|
+
return changed;
|
|
2546
|
+
}
|
|
2547
|
+
/** Whether a refused entry holds `path`, as the outbox was last read. */
|
|
2548
|
+
holds(path) {
|
|
2549
|
+
return this.queue.some((entry) => entry.refused?.paths.includes(path));
|
|
2550
|
+
}
|
|
2551
|
+
/** releaseHeld for a caller without the write lock; nothing when no refused entry holds `path`. */
|
|
2552
|
+
async release(path) {
|
|
2553
|
+
if (this.holds(path)) await this.whenFree(() => this.editQueue((entries) => this.releaseHeld(entries, path)));
|
|
1985
2554
|
}
|
|
1986
2555
|
/** Send one queued entry as `sent`; false when the server cannot be reached, so it stays queued. `again`: see request. */
|
|
1987
2556
|
async sendEntry(entry, again) {
|
|
1988
2557
|
const request = entry.sent;
|
|
1989
2558
|
const paths = entryPaths(entry);
|
|
2559
|
+
if (!again && entry.kind === "patch" && request.op === "file.patch") this.trace(`patch ${entry.path} ${entry.id} base ${request.base?.slice(0, 7) ?? "null"} text ${short(request.text)} writer ${short(entry.text)} baseText ${short(entry.baseText ?? null)}`);
|
|
1990
2560
|
for (const path of paths) this.inflight.set(path, (this.inflight.get(path) ?? 0) + 1);
|
|
1991
2561
|
try {
|
|
1992
2562
|
const response = await this.request(entry, request, again);
|
|
1993
2563
|
if (response === null) return false;
|
|
1994
2564
|
this.answer(entry);
|
|
1995
2565
|
if (entry.kind === "op") await this.settleQueuedOp(entry, response);
|
|
1996
|
-
else if (
|
|
1997
|
-
else if (entry.kind === "patch" && request.op === "text.replace") await this.settleReplace(entry
|
|
1998
|
-
else if (entry.kind === "patch" && request.op === "file.patch") await this.settleEdit(entry
|
|
2566
|
+
else if (entry.kind === "remove") await this.settleRemove(entry, response);
|
|
2567
|
+
else if (entry.kind === "patch" && request.op === "text.replace") await this.settleReplace(entry, response);
|
|
2568
|
+
else if (entry.kind === "patch" && request.op === "file.patch") await this.settleEdit(entry, response, {
|
|
1999
2569
|
writer: entry.text,
|
|
2000
2570
|
base: entry.baseText,
|
|
2001
2571
|
last: entry.replaced
|
|
@@ -2012,25 +2582,16 @@ var SyncClient = class extends EventEmitter {
|
|
|
2012
2582
|
/** A queued operation answered: tie what was written here to the server's versions, or report a refusal. */
|
|
2013
2583
|
async settleQueuedOp(entry, response) {
|
|
2014
2584
|
if (!response.ok) {
|
|
2015
|
-
const
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
saved.
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
path,
|
|
2023
|
-
saved: at,
|
|
2024
|
-
message: `the server refused ${entry.request.op} (${response.message}); ${path} as it was written here was saved to ${at} and the file has the server's version again`
|
|
2025
|
-
});
|
|
2026
|
-
}
|
|
2027
|
-
this.settled(entry.id, {
|
|
2028
|
-
state: "refused",
|
|
2585
|
+
const told = Object.entries(entry.written).flatMap(([path, text]) => text === null ? [] : [{
|
|
2586
|
+
kind: "refused",
|
|
2587
|
+
path,
|
|
2588
|
+
text,
|
|
2589
|
+
message: (saved) => `the server refused ${entry.request.op} (${response.message}); ${path} as it was written here was saved to ${saved} and the file has the server's version again`
|
|
2590
|
+
}]);
|
|
2591
|
+
return this.refuse(entry, told, {
|
|
2029
2592
|
reason: response.reason,
|
|
2030
|
-
message: response.message
|
|
2031
|
-
saved
|
|
2593
|
+
message: response.message
|
|
2032
2594
|
});
|
|
2033
|
-
return;
|
|
2034
2595
|
}
|
|
2035
2596
|
this.settled(entry.id, {
|
|
2036
2597
|
state: "applied",
|
|
@@ -2041,35 +2602,41 @@ var SyncClient = class extends EventEmitter {
|
|
|
2041
2602
|
if (file.kind !== "file") continue;
|
|
2042
2603
|
const written = Object.entries(entry.written).find(([path]) => renumberPath(path, response.renumbered) === file.path)?.[1];
|
|
2043
2604
|
const local = written == null ? void 0 : renumberText(written, response.renumbered, scopeOfPath(file.path));
|
|
2044
|
-
if (local
|
|
2605
|
+
if (local === void 0) continue;
|
|
2606
|
+
this.remember(file.path, {
|
|
2045
2607
|
text: local,
|
|
2046
2608
|
server: file.text
|
|
2047
2609
|
});
|
|
2610
|
+
if (local === written) this.confirmWritten(file.path, local);
|
|
2048
2611
|
}
|
|
2049
2612
|
await Promise.all(response.files.filter((f) => f.kind !== "dir").map((f) => this.delivery(f.path, response.seq)));
|
|
2050
2613
|
}
|
|
2051
2614
|
/**
|
|
2052
2615
|
* The server gave some of the entry's provisional ids away: rewrite the files written offline, the queued requests
|
|
2053
2616
|
* and edits after it, and what the client expects on disk (docs/sync-protocol.md). Called under the write lock with
|
|
2054
|
-
* the outbox as read, so a writer appending meanwhile sees either the old ids everywhere or the new.
|
|
2055
|
-
*
|
|
2056
|
-
*
|
|
2057
|
-
*
|
|
2617
|
+
* the outbox as read, so a writer appending meanwhile sees either the old ids everywhere or the new. The outbox is
|
|
2618
|
+
* sent in order, so every entry after it is still to be sent; one sent already (`sent`), as an older sync client left
|
|
2619
|
+
* behind an operation when it sent edits outside the outbox, stays as the server may have taken it, and so do the
|
|
2620
|
+
* files only it wrote; the server's version of them comes back once it is answered. An edit queued on top of it
|
|
2621
|
+
* keeps that text as its base, so its renumbering is what it changes, and it puts the server's id there when it
|
|
2622
|
+
* lands. A sent write that names a renumbered id is reported (`stale-id`) unless such an edit follows it: the server
|
|
2623
|
+
* takes the id as sent, where it names another environment's entry, and nothing else after it changes that id there:
|
|
2624
|
+
* an Edit sends only its replacement, an operation only what it does, and an edit on another base leaves the line as
|
|
2625
|
+
* the sent write wrote it. A refused write is no write to send.
|
|
2058
2626
|
*/
|
|
2059
2627
|
renumber(entries, entry, renumbered) {
|
|
2060
|
-
const rest = entries.filter((queued) => queued.id !== entry.id && queued.sent === void 0);
|
|
2628
|
+
const rest = entries.filter((queued) => queued.id !== entry.id && queued.sent === void 0 && waiting(queued));
|
|
2061
2629
|
const offlinePaths = [...entryPaths(entry), ...rest.flatMap(entryPaths)];
|
|
2062
|
-
|
|
2063
|
-
this.commitTracked(storage, renumberFiles(storage, offlinePaths, renumbered));
|
|
2630
|
+
this.renumberDisk(offlinePaths, renumbered);
|
|
2064
2631
|
const files = (record) => Object.fromEntries(Object.entries(record).map(([path, text]) => {
|
|
2065
2632
|
const to = renumberPath(path, renumbered);
|
|
2066
2633
|
return [to, text === null ? null : renumberText(text, renumbered, scopeOfPath(to))];
|
|
2067
2634
|
}));
|
|
2068
2635
|
const sentText = new Set(entries.flatMap((queued) => queued.id !== entry.id && queued.kind === "patch" && queued.sent !== void 0 ? [`${queued.path}\0${queued.text}`] : []));
|
|
2636
|
+
const fixedLater = (queued) => rest.some((later) => later.kind === "patch" && later.path === queued.path && !later.replace && later.baseText === queued.text);
|
|
2069
2637
|
for (const queued of entries) {
|
|
2070
|
-
if (queued.id === entry.id || queued.kind !== "patch" || queued.sent
|
|
2071
|
-
|
|
2072
|
-
this.info(queued.path, `sent before ${renumbered.map((r) => `${r.from} → ${r.to}`).join(", ")} was known, so it keeps the ids as the server took them`);
|
|
2638
|
+
if (queued.id === entry.id || queued.kind !== "patch" || !queued.sent || !waiting(queued) || fixedLater(queued)) continue;
|
|
2639
|
+
this.staleIds(queued.path, queued.sent, renumbered);
|
|
2073
2640
|
}
|
|
2074
2641
|
for (const queued of rest) if (queued.kind === "op") {
|
|
2075
2642
|
queued.request = renumberRequest(queued.request, renumbered);
|
|
@@ -2081,44 +2648,66 @@ var SyncClient = class extends EventEmitter {
|
|
|
2081
2648
|
if (queued.kind === "patch") {
|
|
2082
2649
|
queued.text = renumberText(queued.text, renumbered, scopeOfPath(queued.path));
|
|
2083
2650
|
if (queued.replaced !== void 0) queued.replaced = renumberText(queued.replaced, renumbered, scopeOfPath(queued.path));
|
|
2651
|
+
if (queued.replace) queued.replace = {
|
|
2652
|
+
old: renumberText(queued.replace.old, renumbered, scopeOfPath(queued.path)),
|
|
2653
|
+
new: renumberText(queued.replace.new, renumbered, scopeOfPath(queued.path))
|
|
2654
|
+
};
|
|
2084
2655
|
if (queued.baseText !== void 0 && !sentText.has(`${from}\0${queued.baseText}`)) queued.baseText = renumberText(queued.baseText, renumbered, scopeOfPath(queued.path));
|
|
2085
2656
|
}
|
|
2086
2657
|
}
|
|
2087
2658
|
}
|
|
2659
|
+
/** A write of `path` sent as `sent` before `renumbered` was known: the writer is told of each id it names. */
|
|
2660
|
+
staleIds(path, sent, renumbered) {
|
|
2661
|
+
const text = sent.op === "file.patch" ? sent.text : sent.op === "text.replace" ? sent.new : null;
|
|
2662
|
+
if (text === null) return;
|
|
2663
|
+
const named = renumbered.filter((r) => renumberText(text, [r], scopeOfPath(path)) !== text);
|
|
2664
|
+
if (named.length === 0) return;
|
|
2665
|
+
const ids = named.map((r) => r.from).join(", ");
|
|
2666
|
+
this.problem({
|
|
2667
|
+
kind: "stale-id",
|
|
2668
|
+
path,
|
|
2669
|
+
renumbered: named,
|
|
2670
|
+
message: `${path} was sent before the sync server renumbered ${named.map((r) => `${r.from} → ${r.to}`).join(", ")}, and the server takes it as it was sent: ${ids} there now names the entry the server gave that id to, another environment's, and the entry meant here is ${named.map((r) => r.to).join(", ")}; change the reference if it meant the entry added here`
|
|
2671
|
+
});
|
|
2672
|
+
}
|
|
2088
2673
|
/**
|
|
2089
|
-
*
|
|
2090
|
-
* renumbered outbox holds
|
|
2674
|
+
* Renumber the local files at `paths`, and what the client expects there: what it expected before, renumbered, which
|
|
2675
|
+
* the renumbered outbox holds as versions. A direct edit a file holds that the client has not read yet, written while
|
|
2676
|
+
* the operation waited for its answer, is renumbered on disk with the rest and stays a difference from what the client
|
|
2677
|
+
* expects, so the reconcile after the operation leaves the outbox queues it, instead of taking it for what the outbox
|
|
2678
|
+
* wrote. Only a path whose expected text is unknown takes what the file holds. It does not take the write lock: its
|
|
2679
|
+
* caller holds it.
|
|
2091
2680
|
*/
|
|
2092
|
-
|
|
2093
|
-
const
|
|
2681
|
+
renumberDisk(paths, renumbered) {
|
|
2682
|
+
const storage = {
|
|
2094
2683
|
...nodeStorage(this.root),
|
|
2095
2684
|
lock: (fn) => fn()
|
|
2096
2685
|
};
|
|
2097
|
-
const
|
|
2098
|
-
|
|
2099
|
-
|
|
2686
|
+
const seen = new Map([...new Set(paths)].filter(isSynced).map((path) => [path, this.expected.get(path)]));
|
|
2687
|
+
const changes = renumberFiles(storage, seen.keys(), renumbered);
|
|
2688
|
+
const unknown = /* @__PURE__ */ new Set();
|
|
2689
|
+
for (const [path, disk] of seen) {
|
|
2690
|
+
const to = renumberPath(path, renumbered);
|
|
2691
|
+
if (to !== path) this.expected.set(path, null);
|
|
2692
|
+
if (disk === null) this.expected.set(to, null);
|
|
2693
|
+
else if (disk?.text !== void 0) {
|
|
2694
|
+
const text = renumberText(disk.text, renumbered, scopeOfPath(path));
|
|
2695
|
+
this.expected.set(to, {
|
|
2696
|
+
hash: contentHash(text),
|
|
2697
|
+
text
|
|
2698
|
+
});
|
|
2699
|
+
} else unknown.add(to);
|
|
2700
|
+
}
|
|
2701
|
+
this.commit(storage, changes);
|
|
2702
|
+
for (const path of unknown) {
|
|
2703
|
+
const text = storage.read(path);
|
|
2100
2704
|
this.expected.set(path, text === null ? null : {
|
|
2101
2705
|
hash: contentHash(text),
|
|
2102
2706
|
text
|
|
2103
2707
|
});
|
|
2104
|
-
}
|
|
2105
|
-
return {
|
|
2106
|
-
...base,
|
|
2107
|
-
write: (path, text) => {
|
|
2108
|
-
base.write(path, text);
|
|
2109
|
-
track(path);
|
|
2110
|
-
},
|
|
2111
|
-
append: (path, text) => {
|
|
2112
|
-
base.append(path, text);
|
|
2113
|
-
track(path);
|
|
2114
|
-
},
|
|
2115
|
-
remove: (path) => {
|
|
2116
|
-
base.remove(path);
|
|
2117
|
-
track(path);
|
|
2118
|
-
}
|
|
2119
|
-
};
|
|
2708
|
+
}
|
|
2120
2709
|
}
|
|
2121
|
-
|
|
2710
|
+
commit(storage, changes) {
|
|
2122
2711
|
for (const change of changes) if (change.kind === "write") storage.write(change.path, change.text);
|
|
2123
2712
|
else if (change.kind === "append") storage.append(change.path, change.text);
|
|
2124
2713
|
else if (change.kind === "makeDir") storage.makeDir(change.path);
|
|
@@ -2137,7 +2726,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
2137
2726
|
await this.idle(timeoutMs);
|
|
2138
2727
|
return {
|
|
2139
2728
|
problems: this.drain(),
|
|
2140
|
-
queued: this.
|
|
2729
|
+
queued: this.queued,
|
|
2141
2730
|
online: this.online
|
|
2142
2731
|
};
|
|
2143
2732
|
}
|
|
@@ -2150,75 +2739,64 @@ var SyncClient = class extends EventEmitter {
|
|
|
2150
2739
|
if (this.caughtUp && isSynced(path) && edit.old !== "") await this.withChain(path, () => this.replayEdit(edit));
|
|
2151
2740
|
return this.flush(timeoutMs);
|
|
2152
2741
|
}
|
|
2742
|
+
/**
|
|
2743
|
+
* Queue an Edit as its replacement, which the server makes again on its latest version, based as layer 1 knows the
|
|
2744
|
+
* version the Edit changed, the one on disk. One the watcher queued as a whole text already, or that is not the plain
|
|
2745
|
+
* replacement of the version on disk, is left to the flush. The Edit's result is adopted in the hold of the write lock
|
|
2746
|
+
* that queues it (I2); one changed there since it was read, as by an operation written on it, which then has the Edit
|
|
2747
|
+
* queued ahead of it as a direct edit, is reconciled again.
|
|
2748
|
+
*/
|
|
2153
2749
|
async replayEdit({ path, old, new: replacement }) {
|
|
2154
|
-
for (let i = 0; i < 400 && this.isBusy(path); i++) await sleep$1(50);
|
|
2155
2750
|
const disk = this.expected.get(path);
|
|
2156
2751
|
const local = await this.readLocal(path);
|
|
2157
2752
|
if (local.kind !== "text" || disk?.text === void 0 || contentHash(local.text) === disk.hash) return;
|
|
2158
2753
|
if (disk.text.split(old).length !== 2 || disk.text.replace(old, () => replacement) !== local.text) return;
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2754
|
+
const edited = local.text;
|
|
2755
|
+
const { hash, text } = disk;
|
|
2756
|
+
const id = await this.enqueue((entries) => {
|
|
2757
|
+
if (this.expected.get(path)?.hash !== hash || !this.onDisk(path, edited)) return null;
|
|
2758
|
+
this.expected.set(path, {
|
|
2759
|
+
hash: contentHash(edited),
|
|
2760
|
+
text: edited
|
|
2761
|
+
});
|
|
2762
|
+
const start = this.versions(path, entries).find((v) => v.text === text) ?? {
|
|
2763
|
+
text,
|
|
2764
|
+
server: text
|
|
2765
|
+
};
|
|
2766
|
+
return {
|
|
2767
|
+
...editOn(path, edited, start, hash),
|
|
2768
|
+
replace: {
|
|
2769
|
+
old,
|
|
2770
|
+
new: replacement
|
|
2771
|
+
}
|
|
2772
|
+
};
|
|
2162
2773
|
});
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
}, disk.hash);
|
|
2167
|
-
const request = {
|
|
2168
|
-
protocol: 2,
|
|
2169
|
-
op: "text.replace",
|
|
2170
|
-
path,
|
|
2171
|
-
old,
|
|
2172
|
-
new: replacement
|
|
2173
|
-
};
|
|
2174
|
-
const id = entryId();
|
|
2175
|
-
const firstSentSeq = this.hubSeq;
|
|
2176
|
-
this.trace(`replace ${path} ${id}`);
|
|
2177
|
-
this.inflight.set(path, (this.inflight.get(path) ?? 0) + 1);
|
|
2178
|
-
this.awaitAnswer(path, local.text);
|
|
2179
|
-
const queued = this.queuedIds();
|
|
2180
|
-
try {
|
|
2181
|
-
let response;
|
|
2182
|
-
try {
|
|
2183
|
-
response = await this.api.send({
|
|
2184
|
-
...request,
|
|
2185
|
-
requestId: id
|
|
2186
|
-
});
|
|
2187
|
-
} catch (error) {
|
|
2188
|
-
if (!(error instanceof Unreachable)) throw error;
|
|
2189
|
-
return await this.enqueue(write, {
|
|
2190
|
-
queued,
|
|
2191
|
-
...error.sent ? { sent: {
|
|
2192
|
-
id,
|
|
2193
|
-
firstSentSeq,
|
|
2194
|
-
sent: request
|
|
2195
|
-
} } : {}
|
|
2196
|
-
});
|
|
2197
|
-
}
|
|
2198
|
-
await this.settleReplace(path, response, local.text);
|
|
2199
|
-
} finally {
|
|
2200
|
-
this.answeredDirect(path, local.text);
|
|
2201
|
-
}
|
|
2774
|
+
if (id !== null) return this.trace(`replace ${path} ${id}`);
|
|
2775
|
+
this.trace(`replace ${path} changed before it was queued; reconciling it again`);
|
|
2776
|
+
this.schedule(path);
|
|
2202
2777
|
}
|
|
2203
|
-
/** What the server answered to
|
|
2204
|
-
async settleReplace(
|
|
2778
|
+
/** What the server answered to Edit `entry` replayed on its version (its text is the edited text). */
|
|
2779
|
+
async settleReplace(entry, response) {
|
|
2780
|
+
const { path, text: writer } = entry;
|
|
2205
2781
|
if (response.ok) {
|
|
2206
2782
|
const file = response.files.find((f) => f.path === path);
|
|
2207
2783
|
const result = file?.kind === "file" ? file.text : this.cloud.get(path) ?? writer;
|
|
2208
|
-
if (typeof result === "string")
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2784
|
+
if (typeof result === "string") {
|
|
2785
|
+
this.remember(path, {
|
|
2786
|
+
text: writer,
|
|
2787
|
+
server: result
|
|
2788
|
+
});
|
|
2789
|
+
this.confirmWritten(path, writer);
|
|
2790
|
+
}
|
|
2212
2791
|
if (file) await this.delivery(path, response.seq);
|
|
2213
2792
|
return;
|
|
2214
2793
|
}
|
|
2215
|
-
|
|
2216
|
-
this.problem({
|
|
2794
|
+
await this.refuse(entry, [{
|
|
2217
2795
|
kind: "edit-rejected",
|
|
2218
2796
|
path,
|
|
2219
|
-
|
|
2220
|
-
message: `${path}: the Edit could not be replayed on the latest version (${response.message}); the edited text was saved to ${saved} and the file now has the latest version. Read it and edit again`
|
|
2221
|
-
});
|
|
2797
|
+
text: writer,
|
|
2798
|
+
message: (saved) => `${path}: the Edit could not be replayed on the latest version (${response.message}); the edited text was saved to ${saved} and the file now has the latest version. Read it and edit again`
|
|
2799
|
+
}]);
|
|
2222
2800
|
if ("files" in response && response.files.some((f) => f.path === path)) await this.delivery(path, response.seq);
|
|
2223
2801
|
}
|
|
2224
2802
|
async walk(dir = "") {
|
|
@@ -2271,52 +2849,69 @@ var SyncClient = class extends EventEmitter {
|
|
|
2271
2849
|
}
|
|
2272
2850
|
if (immediate) await Promise.allSettled(this.chains.values());
|
|
2273
2851
|
}
|
|
2852
|
+
/**
|
|
2853
|
+
* Watch the directories that may hold synced files, and local/sync/ for the outbox. The client's own files in the
|
|
2854
|
+
* directories under local/sync/, such as the texts in known/ and the files it prepares in tmp/, are not watched.
|
|
2855
|
+
*/
|
|
2274
2856
|
startWatcher() {
|
|
2275
|
-
const timers =
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
if (!name) return;
|
|
2279
|
-
const path = String(name).split(sep).join("/");
|
|
2280
|
-
if (path === OUTBOX) {
|
|
2281
|
-
clearTimeout(timers.get(path));
|
|
2282
|
-
timers.set(path, setTimeout(() => {
|
|
2283
|
-
timers.delete(path);
|
|
2284
|
-
this.outboxChanged();
|
|
2285
|
-
}, SETTLE_MS));
|
|
2286
|
-
return;
|
|
2287
|
-
}
|
|
2288
|
-
if (!isSynced(path)) return;
|
|
2857
|
+
const timers = this.watchTimers;
|
|
2858
|
+
const changed = (path) => {
|
|
2859
|
+
if (path === OUTBOX) {
|
|
2289
2860
|
clearTimeout(timers.get(path));
|
|
2290
2861
|
timers.set(path, setTimeout(() => {
|
|
2291
2862
|
timers.delete(path);
|
|
2292
|
-
this.
|
|
2863
|
+
this.outboxChanged();
|
|
2293
2864
|
}, SETTLE_MS));
|
|
2865
|
+
return;
|
|
2866
|
+
}
|
|
2867
|
+
if (!isSynced(path)) return;
|
|
2868
|
+
this.trace(`watched ${path}`);
|
|
2869
|
+
clearTimeout(timers.get(path));
|
|
2870
|
+
timers.set(path, setTimeout(() => {
|
|
2871
|
+
timers.delete(path);
|
|
2872
|
+
this.track(this.whenSettled(path).then(() => this.schedule(path)));
|
|
2873
|
+
}, SETTLE_MS));
|
|
2874
|
+
};
|
|
2875
|
+
try {
|
|
2876
|
+
this.watcher = watchTree(this.paths.data, {
|
|
2877
|
+
include: watchedDir,
|
|
2878
|
+
onChange: changed,
|
|
2879
|
+
onError: (error, dir, stopped) => this.log(`file watcher of .mainflow/${dir === "" ? "" : `${dir}/`} ${stopped ? "stopped" : "could not start"}: ${error.message}; the rescan finds its changes`)
|
|
2294
2880
|
});
|
|
2295
|
-
this.watcher.on("error", (error) => this.log(`file watcher stopped: ${error.message}; the rescan continues`));
|
|
2296
2881
|
} catch (error) {
|
|
2297
2882
|
this.log(`no file watcher (${error.message}); relying on the rescan`);
|
|
2298
2883
|
}
|
|
2299
2884
|
}
|
|
2885
|
+
/** Resolve once two looks at the file SETTLE_POLL_MS apart find the same size and modification time, or after SETTLE_LIMIT_MS. */
|
|
2300
2886
|
async whenSettled(path) {
|
|
2301
2887
|
const file = join(this.paths.data, path);
|
|
2302
2888
|
let previous = "";
|
|
2303
|
-
for (let i = 0; i <
|
|
2889
|
+
for (let i = 0; i < SETTLE_LIMIT_MS / SETTLE_POLL_MS; i++) {
|
|
2304
2890
|
const info = await stat(file).catch(() => null);
|
|
2305
2891
|
const signature = info ? `${info.mtimeMs}:${info.size}` : "absent";
|
|
2306
2892
|
if (signature === previous) return;
|
|
2307
2893
|
previous = signature;
|
|
2308
|
-
await sleep$1(
|
|
2894
|
+
await sleep$1(SETTLE_POLL_MS);
|
|
2309
2895
|
}
|
|
2310
2896
|
}
|
|
2311
|
-
|
|
2897
|
+
/**
|
|
2898
|
+
* Tell the writer: notices.jsonl first, written before anything else, with the outbox entry the problem is about,
|
|
2899
|
+
* which tells a later run what a refusal it finds was told of already (resumeRefusals).
|
|
2900
|
+
*/
|
|
2901
|
+
problem(problem, entry) {
|
|
2902
|
+
try {
|
|
2903
|
+
appendFileSync(this.paths.notices, `${JSON.stringify({
|
|
2904
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2905
|
+
level: "warn",
|
|
2906
|
+
...problem,
|
|
2907
|
+
...entry === void 0 ? {} : { entry }
|
|
2908
|
+
})}\n`);
|
|
2909
|
+
} catch (error) {
|
|
2910
|
+
this.log(`could not write local/sync/notices.jsonl: ${error.message}`);
|
|
2911
|
+
}
|
|
2312
2912
|
this.problems.push(problem);
|
|
2313
2913
|
this.log(`! ${problem.message}`);
|
|
2314
2914
|
this.emit("problem", problem);
|
|
2315
|
-
appendFile(this.paths.notices, `${JSON.stringify({
|
|
2316
|
-
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2317
|
-
level: "warn",
|
|
2318
|
-
...problem
|
|
2319
|
-
})}\n`).catch(() => {});
|
|
2320
2915
|
}
|
|
2321
2916
|
/** Something worth recording that does not concern the writer, such as a merge that only added others' changes. */
|
|
2322
2917
|
info(path, message) {
|
|
@@ -2331,21 +2926,33 @@ var SyncClient = class extends EventEmitter {
|
|
|
2331
2926
|
message
|
|
2332
2927
|
})}\n`).catch(() => {});
|
|
2333
2928
|
}
|
|
2334
|
-
|
|
2929
|
+
/** A new name under local/sync/conflicts/ for a text of `path`, relative to .mainflow/. */
|
|
2930
|
+
conflictName(path) {
|
|
2335
2931
|
const name = `${path.split("/").join("__")}.${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.${Math.random().toString(36).slice(2, 6)}`;
|
|
2336
|
-
await mkdir(this.paths.conflicts, { recursive: true });
|
|
2337
|
-
await writeFile(join(this.paths.conflicts, name), text);
|
|
2338
2932
|
return relative(this.paths.data, join(this.paths.conflicts, name)).split(sep).join("/");
|
|
2339
2933
|
}
|
|
2934
|
+
/** Save `text` as `saved`, relative to .mainflow/; a text saved there already is kept. */
|
|
2935
|
+
async saveText(saved, text) {
|
|
2936
|
+
await mkdir(this.paths.conflicts, { recursive: true });
|
|
2937
|
+
await writeFile(join(this.paths.data, saved), text, { flag: "wx" }).catch((error) => {
|
|
2938
|
+
if (error.code !== "EEXIST") throw error;
|
|
2939
|
+
});
|
|
2940
|
+
}
|
|
2941
|
+
async saveConflict(path, text) {
|
|
2942
|
+
const saved = this.conflictName(path);
|
|
2943
|
+
await this.saveText(saved, text);
|
|
2944
|
+
return saved;
|
|
2945
|
+
}
|
|
2340
2946
|
saveKnown() {
|
|
2341
2947
|
if (this.knownTimer) return;
|
|
2342
2948
|
this.knownTimer = setTimeout(() => this.saveKnownNow(), 200);
|
|
2343
2949
|
}
|
|
2344
|
-
saveKnownNow() {
|
|
2950
|
+
saveKnownNow(final = false) {
|
|
2951
|
+
if (this.abandoned && !final) return;
|
|
2345
2952
|
clearTimeout(this.knownTimer);
|
|
2346
2953
|
this.knownTimer = void 0;
|
|
2347
2954
|
const files = {};
|
|
2348
|
-
for (const [path, disk] of this.
|
|
2955
|
+
for (const [path, disk] of this.confirmed) if (disk) files[path] = disk;
|
|
2349
2956
|
try {
|
|
2350
2957
|
underLock(this.root, () => writeKnown(this.root, files));
|
|
2351
2958
|
} catch (error) {
|
|
@@ -2459,6 +3066,10 @@ async function serveIpc(root, client) {
|
|
|
2459
3066
|
}
|
|
2460
3067
|
try {
|
|
2461
3068
|
if (req.url === "/wait") return reply(200, await client.outcome(String(body.id), typeof body.timeoutMs === "number" ? body.timeoutMs : 0));
|
|
3069
|
+
if (req.url === "/send") {
|
|
3070
|
+
reply(200, {});
|
|
3071
|
+
return client.outboxChanged();
|
|
3072
|
+
}
|
|
2462
3073
|
if (req.url === "/flush") {
|
|
2463
3074
|
const edit = body.edit;
|
|
2464
3075
|
return reply(200, edit ? await client.flushEdit(edit) : await client.flush());
|
|
@@ -2655,17 +3266,31 @@ function queueOperation(root, request) {
|
|
|
2655
3266
|
};
|
|
2656
3267
|
});
|
|
2657
3268
|
}
|
|
2658
|
-
/** How many writes wait in the outbox, as queue.json says. */
|
|
3269
|
+
/** How many writes wait in the outbox for the server, as queue.json says: not the ones it refused (src/state.ts). */
|
|
2659
3270
|
function queuedCount(root) {
|
|
2660
3271
|
const file = syncPaths(root).queue;
|
|
2661
3272
|
if (!existsSync(file)) return 0;
|
|
2662
3273
|
try {
|
|
2663
|
-
return JSON.parse(readFileSync(file, "utf8")).entries
|
|
3274
|
+
return (JSON.parse(readFileSync(file, "utf8")).entries ?? []).filter(waiting).length;
|
|
2664
3275
|
} catch {
|
|
2665
3276
|
return 0;
|
|
2666
3277
|
}
|
|
2667
3278
|
}
|
|
2668
3279
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3280
|
+
/** How long a writer waits for the sync client to take its notice; one that does not answer by then finds it anyway. */
|
|
3281
|
+
const NOTIFY_MS = 200;
|
|
3282
|
+
/**
|
|
3283
|
+
* Tell the hub's sync client that the outbox has a new entry, so it sends it now, for a writer that does not wait for
|
|
3284
|
+
* the server's answer, such as a hook or the interface. Without a sync client, or one that does not answer, nothing
|
|
3285
|
+
* happens: the client's watcher of queue.json and its rescan find the entry, a moment later.
|
|
3286
|
+
*/
|
|
3287
|
+
async function notifyQueued(root) {
|
|
3288
|
+
const daemon = readDaemon(root);
|
|
3289
|
+
if (!daemon) return;
|
|
3290
|
+
try {
|
|
3291
|
+
await callDaemon(daemon, "/send", {}, NOTIFY_MS);
|
|
3292
|
+
} catch {}
|
|
3293
|
+
}
|
|
2669
3294
|
/**
|
|
2670
3295
|
* Ask the sync client what became of entry `id`, giving it up to `timeoutMs`. A sync client that is still starting is
|
|
2671
3296
|
* waited for. `waiting` when it is offline, or the time ran out first.
|
|
@@ -2838,7 +3463,10 @@ function hubWriter(root, env = process.env, options = {}) {
|
|
|
2838
3463
|
...queued,
|
|
2839
3464
|
stopped: `mainflow sync is not running (${start.reason}); the write waits in .mainflow/local/sync/queue.json until it runs`
|
|
2840
3465
|
};
|
|
2841
|
-
if (options.confirmMs === void 0)
|
|
3466
|
+
if (options.confirmMs === void 0) {
|
|
3467
|
+
await notifyQueued(root);
|
|
3468
|
+
return queued;
|
|
3469
|
+
}
|
|
2842
3470
|
const outcome = await awaitOutcome(root, id, options.confirmMs);
|
|
2843
3471
|
if (outcome.state === "applied") {
|
|
2844
3472
|
const { response } = outcome;
|