@torrent-tv/proxy 2.95.7 → 2.95.9

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/bin/cli.js CHANGED
@@ -24,7 +24,7 @@ import { createTunnelClient } from "../services/transport/tunnel-client.js";
24
24
  import { createWebRtcManager } from "../services/transport/webrtc-manager.js";
25
25
  import { createDataChannelHandler } from "../services/transport/data-channel-handler.js";
26
26
  import { dumpsToRemove, pruneCoreDumps } from "../services/storage/core-dumps.js";
27
- import { availableMemoryBytes } from "../services/storage/machine-memory.js";
27
+ import { availableMemory } from "../services/storage/machine-memory.js";
28
28
  import { Diagnostics } from "../services/storage/Diagnostics.js";
29
29
  import { adoptOrphanRingFiles, createPacketWitness, pruneWitnessCaptures } from "../services/transport/packet-witness.js";
30
30
  import { createUsrsctpStateReader } from "../services/transport/usrsctp-state.js";
@@ -586,7 +586,7 @@ try {
586
586
  const encode = started?.outputParts?.admission?.headroom?.() ?? null;
587
587
  return {
588
588
  metrics: {
589
- ...collectHealthMetrics({ availableMemoryBytes }),
589
+ ...collectHealthMetrics({ availableMemory }),
590
590
  ...(encode ? { encodeSpeedX: encode.encodeSpeedX, encodeOccupiedCostSec: encode.occupiedCostSec } : {})
591
591
  },
592
592
  holds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.95.7",
3
+ "version": "2.95.9",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @file How much of the machine's memory is available, read once.
2
+ * @file How much memory an allocation of this process could obtain, read once.
3
3
  *
4
4
  * It was written THREE TIMES, in three layers, with the same body each time:
5
5
  * `transport/health-collector.js` for the score a proxy publishes to the registry,
@@ -22,35 +22,264 @@
22
22
  * estimate of what an allocation could actually obtain. Where there is no
23
23
  * `/proc` — not Linux, or it is unreadable — `freemem` is the best answer there
24
24
  * is, and on those systems it is not misleading.
25
+ *
26
+ * **Why a container's limit as well.** `/proc/meminfo` describes the HOST. A
27
+ * proxy run under Docker's `--memory` or systemd's `MemoryMax=` is killed by
28
+ * the kernel at its cgroup's limit whatever the host has free: on 2026-10-07 a
29
+ * 512 MiB container was killed while this reading said 4437 MB
30
+ * (torrent-tv/meta#153). So the answer is the smaller of the host's figure and
31
+ * the room left under every memory limit on this process's cgroup path — its
32
+ * own and each ancestor's, because a slice limit binds every unit inside it.
33
+ * The proxy runs anywhere, so nothing here knows of any particular host: the
34
+ * cgroup is found from `/proc/self/cgroup`, and where nothing is readable the
35
+ * answer is the host's alone, as it always was.
36
+ *
37
+ * **The room under a limit is NOT `limit − usage`,** for the same reason the
38
+ * host's figure is not `freemem`: the cgroup is charged for the file pages its
39
+ * processes read and write, and the kernel takes those back before it kills
40
+ * anything. Measured in a 512 MiB container on the Home Assistant host: after a
41
+ * 600 MB file was written `memory.current` stood at 531 MB of 537 — 515 MB of
42
+ * it file cache, 0.12 MB anonymous — and a 450 MB allocation then succeeded
43
+ * with no OOM kill, the cache shrinking to 50 MB. `limit − usage` said 5 MB. So
44
+ * the reclaimable part is added back, and it is the part the kernel's own
45
+ * `MemAvailable` counts: the file LRU lists and reclaimable slab
46
+ * (`active_file`, `inactive_file`, `slab_reclaimable` in cgroup v2's
47
+ * `memory.stat`; v1 states the file lists hierarchically as `total_*`). The
48
+ * watermark share `MemAvailable` subtracts has no counterpart under a cgroup
49
+ * limit, which is hard, so nothing is subtracted for it.
25
50
  */
26
51
 
27
52
  import { readFileSync } from "node:fs";
28
53
  import os from "node:os";
29
54
 
30
55
  /**
31
- * What the machine could give an allocation right now.
56
+ * Where each cgroup version keeps its memory controller, and under which names.
57
+ *
58
+ * Version 1 states the file lists of the cgroup alone as `active_file` and of
59
+ * it with its descendants as `total_active_file`; its usage includes the
60
+ * descendants, so the hierarchical figures are the ones that subtract from it.
61
+ */
62
+ const HIERARCHIES = {
63
+ 1: {
64
+ mount: "/sys/fs/cgroup/memory",
65
+ limit: "memory.limit_in_bytes",
66
+ usage: "memory.usage_in_bytes",
67
+ reclaimable: ["total_active_file", "total_inactive_file"]
68
+ },
69
+ 2: {
70
+ mount: "/sys/fs/cgroup",
71
+ limit: "memory.max",
72
+ usage: "memory.current",
73
+ reclaimable: ["active_file", "inactive_file", "slab_reclaimable"]
74
+ }
75
+ };
76
+
77
+ /**
78
+ * @typedef {Object} MemoryReading
79
+ * @property {number} bytes - What an allocation could obtain right now.
80
+ * @property {"cgroup v2" | "cgroup v1" | "MemAvailable" | "freemem"} source -
81
+ * Which reading decided `bytes`: a container's limit, the kernel's own
82
+ * estimate for the host, or free memory where that estimate was unreadable.
83
+ * @property {number | null} limitBytes - The smallest memory limit in force on
84
+ * this process's cgroup path, or null where there is none. Reported whether
85
+ * or not it decided, so a log says that a limit exists.
86
+ * @property {number} totalBytes - The most this process could ever hold: the
87
+ * limit where there is one, the host's memory otherwise.
88
+ */
89
+
90
+ /**
91
+ * What an allocation of this process could obtain right now.
32
92
  *
33
- * @returns {{ bytes: number, measured: boolean }} `measured` is false where the
34
- * kernel's own estimate was not available, so a caller that prints the figure
35
- * can say which of the two it is holding.
93
+ * @param {Object} [options]
94
+ * @param {(path: string) => string} [options.readFile] - Reads a file as text,
95
+ * throwing where it is absent or unreadable; injected by the checks.
96
+ * @returns {MemoryReading}
36
97
  */
37
- export function availableMemory() {
98
+ export function availableMemory({ readFile = (path) => readFileSync(path, "utf8") } = {}) {
99
+ const hostTotal = os.totalmem();
100
+ const host = hostAvailable(readFile);
101
+ const container = containerRoom(readFile, hostTotal);
102
+ if (container !== null && container.roomBytes < host.bytes) {
103
+ return {
104
+ bytes: container.roomBytes,
105
+ source: `cgroup v${container.version}`,
106
+ limitBytes: container.limitBytes,
107
+ totalBytes: container.limitBytes
108
+ };
109
+ }
110
+ return {
111
+ bytes: host.bytes,
112
+ source: host.source,
113
+ limitBytes: container?.limitBytes ?? null,
114
+ totalBytes: container?.limitBytes ?? hostTotal
115
+ };
116
+ }
117
+
118
+ /**
119
+ * What an allocation of this process could obtain right now, as a plain number.
120
+ *
121
+ * @returns {number}
122
+ */
123
+ export function availableMemoryBytes() {
124
+ return availableMemory().bytes;
125
+ }
126
+
127
+ /**
128
+ * The host's own estimate, or its free memory where the estimate is unreadable.
129
+ *
130
+ * @param {(path: string) => string} readFile
131
+ * @returns {{ bytes: number, source: "MemAvailable" | "freemem" }}
132
+ */
133
+ function hostAvailable(readFile) {
38
134
  try {
39
- const match = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(readFileSync("/proc/meminfo", "utf8"));
135
+ const match = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(readFile("/proc/meminfo"));
40
136
  if (match) {
41
- return { bytes: Number(match[1]) * 1024, measured: true };
137
+ return { bytes: Number(match[1]) * 1024, source: "MemAvailable" };
42
138
  }
43
139
  } catch {
44
140
  // silent-ok: not Linux, or /proc is not readable.
45
141
  }
46
- return { bytes: os.freemem(), measured: false };
142
+ return { bytes: os.freemem(), source: "freemem" };
143
+ }
144
+
145
+ /**
146
+ * The room left under every memory limit on this process's cgroup path.
147
+ *
148
+ * @param {(path: string) => string} readFile
149
+ * @param {number} hostTotal - The host's memory; a limit at or above it cannot
150
+ * be reached by memory use, which is how cgroup v1 says "no limit".
151
+ * @returns {{ version: 1 | 2, roomBytes: number, limitBytes: number } | null}
152
+ * Null where no limit is in force or nothing is readable.
153
+ */
154
+ function containerRoom(readFile, hostTotal) {
155
+ let membership;
156
+ try {
157
+ membership = ownCgroup(readFile("/proc/self/cgroup"));
158
+ } catch {
159
+ return null; // silent-ok: not Linux, or /proc is not readable.
160
+ }
161
+ if (membership === null) {
162
+ return null;
163
+ }
164
+ const hierarchy = HIERARCHIES[membership.version];
165
+ let found = null;
166
+ for (const directory of levelsOf(hierarchy.mount, membership.path)) {
167
+ const level = roomAt(readFile, hierarchy, directory, hostTotal);
168
+ if (level === null) {
169
+ continue;
170
+ }
171
+ found = {
172
+ version: membership.version,
173
+ roomBytes: Math.min(found?.roomBytes ?? Infinity, level.roomBytes),
174
+ limitBytes: Math.min(found?.limitBytes ?? Infinity, level.limitBytes)
175
+ };
176
+ }
177
+ return found;
47
178
  }
48
179
 
49
180
  /**
50
- * What the machine could give an allocation right now, as a plain number.
181
+ * Which cgroup this process is in, for the memory controller.
51
182
  *
183
+ * A host in hybrid mode lists both versions; the memory controller is then on
184
+ * version 1, named in the line that lists it.
185
+ *
186
+ * @param {string} text - `/proc/self/cgroup`.
187
+ * @returns {{ version: 1 | 2, path: string } | null}
188
+ */
189
+ function ownCgroup(text) {
190
+ for (const line of text.split("\n")) {
191
+ const match = /^\d+:([^:]*):(.*)$/.exec(line);
192
+ if (match && match[1].split(",").includes("memory")) {
193
+ return { version: 1, path: match[2] };
194
+ }
195
+ }
196
+ const unified = /^0::(.*)$/m.exec(text);
197
+ return unified ? { version: 2, path: unified[1] } : null;
198
+ }
199
+
200
+ /**
201
+ * The cgroup's own directory and each ancestor's, up to the mount.
202
+ *
203
+ * The own directory may be absent: Docker on cgroup v1 names the host's path
204
+ * while mounting the container's own directory at the root. The mount is
205
+ * always on the list, so in that case it is what is read. A path with `..` is
206
+ * a cgroup outside this namespace's root, which nothing under the mount
207
+ * describes, so nothing is read for it and the host's figure stands.
208
+ *
209
+ * @param {string} mount
210
+ * @param {string} path
211
+ * @returns {string[]}
212
+ */
213
+ function levelsOf(mount, path) {
214
+ const parts = path.split("/").filter(Boolean);
215
+ if (parts.includes("..")) {
216
+ return [];
217
+ }
218
+ const levels = [];
219
+ for (let depth = parts.length; depth >= 0; depth -= 1) {
220
+ levels.push([mount, ...parts.slice(0, depth)].join("/"));
221
+ }
222
+ return levels;
223
+ }
224
+
225
+ /**
226
+ * The limit in one cgroup directory and the room left under it.
227
+ *
228
+ * @param {(path: string) => string} readFile
229
+ * @param {(typeof HIERARCHIES)[1]} hierarchy
230
+ * @param {string} directory
231
+ * @param {number} hostTotal
232
+ * @returns {{ roomBytes: number, limitBytes: number } | null} Null where this
233
+ * level states no limit — `max`, the version 1 sentinel — or is not there.
234
+ */
235
+ function roomAt(readFile, hierarchy, directory, hostTotal) {
236
+ let limitBytes;
237
+ let usageBytes;
238
+ try {
239
+ limitBytes = bytesOf(readFile(`${directory}/${hierarchy.limit}`));
240
+ usageBytes = bytesOf(readFile(`${directory}/${hierarchy.usage}`));
241
+ } catch {
242
+ return null; // silent-ok: no such level, or no memory controller on it.
243
+ }
244
+ if (!Number.isFinite(limitBytes) || limitBytes >= hostTotal || !Number.isFinite(usageBytes)) {
245
+ return null;
246
+ }
247
+ let reclaimableBytes = 0;
248
+ try {
249
+ reclaimableBytes = statSum(readFile(`${directory}/memory.stat`), hierarchy.reclaimable);
250
+ } catch {
251
+ // silent-ok: counting nothing as reclaimable understates the room, which
252
+ // errs away from the kill this reading exists to avoid.
253
+ }
254
+ return { limitBytes, roomBytes: Math.max(0, limitBytes - usageBytes + reclaimableBytes) };
255
+ }
256
+
257
+ /**
258
+ * A byte count written by the kernel, or NaN for anything else (`max`).
259
+ *
260
+ * @param {string} text
52
261
  * @returns {number}
53
262
  */
54
- export function availableMemoryBytes() {
55
- return availableMemory().bytes;
263
+ function bytesOf(text) {
264
+ const trimmed = text.trim();
265
+ return /^\d+$/.test(trimmed) ? Number(trimmed) : Number.NaN;
266
+ }
267
+
268
+ /**
269
+ * The sum of the named fields of a `memory.stat`.
270
+ *
271
+ * @param {string} text
272
+ * @param {string[]} names
273
+ * @returns {number}
274
+ */
275
+ function statSum(text, names) {
276
+ let sum = 0;
277
+ for (const line of text.split("\n")) {
278
+ const [name, value = ""] = line.split(" ");
279
+ if (names.includes(name)) {
280
+ const bytes = bytesOf(value);
281
+ sum += Number.isFinite(bytes) ? bytes : 0;
282
+ }
283
+ }
284
+ return sum;
56
285
  }
@@ -168,13 +168,6 @@ export async function readMappingSummary() {
168
168
  return null;
169
169
  }
170
170
 
171
- /**
172
- * Available memory, from the one place that reads it.
173
- *
174
- * @returns {{ bytes: number, measured: boolean }}
175
- */
176
- export { availableMemory };
177
-
178
171
  /**
179
172
  * Anonymous memory this process holds, from the kernel's own rollup.
180
173
  *
@@ -254,8 +247,8 @@ function megabytes(bytes) {
254
247
  * @param {"process" | "thread"} [reading.scope]
255
248
  * @param {string} [reading.label] - Which thread the isolate figures are of.
256
249
  * @param {{ rss: number, heapUsed: number, heapTotal: number, external: number, arrayBuffers: number }} reading.process
257
- * @param {number} [reading.availableBytes]
258
- * @param {boolean} [reading.availableMeasured]
250
+ * @param {import("./machine-memory.js").MemoryReading} [reading.available] -
251
+ * What an allocation could obtain, and which reading decided it.
259
252
  * @param {number | null} [reading.anonymousBytes]
260
253
  * @param {ReturnType<typeof summariseMappings> | null} [reading.mappings]
261
254
  * @param {number | null} [reading.diskFreeBytes]
@@ -272,8 +265,7 @@ export function describeMemory({
272
265
  scope = "process",
273
266
  label = "",
274
267
  process: usage,
275
- availableBytes,
276
- availableMeasured,
268
+ available,
277
269
  anonymousBytes = null,
278
270
  mappings = null,
279
271
  diskFreeBytes = null,
@@ -312,13 +304,32 @@ export function describeMemory({
312
304
  `memory: rss=${megabytes(usage.rss)} ${isolate}` +
313
305
  `${anonymousBytes === null ? "" : ` anon=${megabytes(anonymousBytes)}`}${shape}; ` +
314
306
  `${storesPart}; ` +
315
- `machine has ${megabytes(availableBytes ?? 0)} available` +
316
- `${availableMeasured ? "" : " (estimated — /proc/meminfo could not be read)"}` +
307
+ describeAvailable(available) +
317
308
  `${diskFreeBytes === null ? "" : `, ${megabytes(diskFreeBytes)} free on disk`}` +
318
309
  tail
319
310
  );
320
311
  }
321
312
 
313
+ /**
314
+ * What an allocation could obtain, with the reading that decided it and the
315
+ * container's limit, so a log says whether a limit is in force.
316
+ *
317
+ * @param {import("./machine-memory.js").MemoryReading | undefined} available
318
+ * @returns {string}
319
+ */
320
+ function describeAvailable(available) {
321
+ if (!available) {
322
+ return "available memory not read";
323
+ }
324
+ const decided = available.source === "freemem"
325
+ ? "estimated from free memory — /proc/meminfo could not be read"
326
+ : `${available.source} decides`;
327
+ const limit = available.limitBytes === null
328
+ ? "no container limit"
329
+ : `container limit ${megabytes(available.limitBytes)}`;
330
+ return `machine has ${megabytes(available.bytes)} available (${decided}; ${limit})`;
331
+ }
332
+
322
333
  /**
323
334
  * The figures whose movement earns a line, each under its own name.
324
335
  *
@@ -540,14 +551,13 @@ export function startMemoryReport({
540
551
  // mapping and a busy process has thousands; the rollup and
541
552
  // `/proc/meminfo` are single lines but still walk page tables, and
542
553
  // the reading now happens once a second rather than once a minute.
543
- const { bytes, measured } = await availableMemory();
554
+ const available = availableMemory();
544
555
  anonymousBytes = await readAnonymousMemory();
545
556
  log(describeMemory({
546
557
  scope,
547
558
  label,
548
559
  process: processMemory,
549
- availableBytes: bytes,
550
- availableMeasured: measured,
560
+ available,
551
561
  anonymousBytes,
552
562
  mappings: await readMappingSummary(),
553
563
  diskFreeBytes: diskPath ? await readDiskFree(diskPath) : null,
@@ -1826,17 +1826,19 @@ export class TorrentPool {
1826
1826
 
1827
1827
  /**
1828
1828
  * Schedule removal of a torrent (with its on-disk store) after
1829
- * {@link TORRENT_IDLE_TTL_MS} of nothing being wanted of it. Idempotent —
1830
- * replaces any existing timer for the torrent.
1829
+ * {@link TORRENT_IDLE_TTL_MS} of nothing being wanted of it. The clock runs
1830
+ * from when it stopped being wanted, so a timer already running is kept:
1831
+ * replacing it on every report of the same state extended the time without
1832
+ * end, and the withdrawal of one map's 4900 zones replaced it 3524 times in
1833
+ * four seconds (Home Assistant 2026-10-07, torrent-tv/meta#95).
1831
1834
  *
1832
1835
  * @param {import("webtorrent").Torrent} torrent
1833
1836
  * @returns {void}
1834
1837
  */
1835
1838
  #scheduleIdleRemoval(torrent) {
1836
- if (!torrent) {
1839
+ if (!torrent || this.#idleTimers.has(torrent)) {
1837
1840
  return;
1838
1841
  }
1839
- this.#cancelIdleRemoval(torrent);
1840
1842
  const name = typeof torrent.name === "string" ? torrent.name : "(unknown)";
1841
1843
  const infoHashShort = String(torrent.infoHash ?? "?").slice(0, 8);
1842
1844
  logger.info(`torrent-pool: scheduling idle removal for "${name}" [${infoHashShort}] in ${TORRENT_IDLE_TTL_MS / 1000}s`);
@@ -16,16 +16,19 @@ import os from "node:os";
16
16
  * Suitable as input to `Math.max(0, 1 - Math.min(1, cpuLoad))` for a
17
17
  * normalised "CPU availability" score.
18
18
  *
19
- * `memFree` — fraction of total system RAM that could still be given out
20
- * (0–1). The reading is the storage component's (`storage/machine-memory.js`
21
- * says why that is not the same as free memory), handed in.
19
+ * `memFree` — fraction of the memory this process could ever hold that could
20
+ * still be given out (0–1): of a container's limit where one is in force, of
21
+ * the host's RAM otherwise — divided by the host's RAM, a 512 MiB container
22
+ * with half its limit free read as nearly full. The reading is the storage
23
+ * component's (`storage/machine-memory.js` says why that is not the same as
24
+ * free memory), handed in.
22
25
  *
23
26
  * `uptime` — process uptime in whole seconds (useful for preferring
24
27
  * already-warmed proxies over freshly started ones).
25
28
  *
26
29
  * @typedef {Object} HealthMetrics
27
30
  * @property {number} cpuLoad - 1-min load avg / cpu-count. 0 = idle, 1 = saturated, >1 = overloaded.
28
- * @property {number} memFree - Memory an allocation could obtain, as a fraction of total RAM (0–1).
31
+ * @property {number} memFree - Memory an allocation could obtain, as a fraction of the most this process could hold (0–1).
29
32
  * @property {number} uptime - Process uptime in seconds.
30
33
  */
31
34
 
@@ -36,14 +39,16 @@ import os from "node:os";
36
39
  * diff noise when serialising to JSON across the tunnel.
37
40
  *
38
41
  * @param {object} params
39
- * @param {() => number} params.availableMemoryBytes - What the machine could
40
- * give an allocation right now; the storage component's reading.
42
+ * @param {() => { bytes: number, totalBytes: number }} params.availableMemory -
43
+ * What an allocation could obtain right now and the most this process could
44
+ * ever hold; the storage component's reading.
41
45
  * @returns {HealthMetrics}
42
46
  */
43
- export function collectHealthMetrics({ availableMemoryBytes }) {
47
+ export function collectHealthMetrics({ availableMemory }) {
44
48
  const cpuCount = os.cpus().length || 1;
45
49
  const cpuLoad = os.loadavg()[0] / cpuCount;
46
- const memFree = availableMemoryBytes() / os.totalmem();
50
+ const { bytes, totalBytes } = availableMemory();
51
+ const memFree = bytes / totalBytes;
47
52
 
48
53
  return {
49
54
  cpuLoad: Math.round(cpuLoad * 1000) / 1000,
@@ -19,13 +19,25 @@ export class DownloadMaps {
19
19
  file.sourceZones = zones.map(zone => Number.isFinite(zone.deadlineAt) || !Number.isFinite(zone.withinSeconds)
20
20
  ? zone : { ...zone, deadlineAt: now + Math.max(0, zone.withinSeconds) * 1000 });
21
21
  let changed = false;
22
+ // Until the new zones are resolved to bytes, a resolved zone stays while
23
+ // the film it covers is still wanted. A viewer moving on re-cuts every zone
24
+ // without making its film unwanted; dropping the zones that lost their
25
+ // exact interval published an empty map every few seconds of playback,
26
+ // which let the swarm go and emptied the encoder's resolved input (Home
27
+ // Assistant 2026-10-07, torrent-tv/meta#95). A zone no new zone overlaps —
28
+ // a seek away — is withdrawn at once.
22
29
  const retained = file.zones.flatMap(zone => {
23
30
  const interval = zone.downloadInterval ?? zone;
24
31
  const current = file.sourceZones.find(candidate => candidate.from === interval.from && candidate.to === interval.to);
25
- const deadlineAt = current && Number.isFinite(current.deadlineAt)
26
- ? current.deadlineAt + (Math.max(0, zone.from - current.from) - (zone.leadSeconds ?? 0)) * 1000 : current?.deadlineAt;
27
- if (!current || Object.keys(current).some(key => key !== "from" && key !== "to" && !Object.is(zone[key], key === "deadlineAt" ? deadlineAt : current[key]))) changed = true;
28
- return current ? [{ ...zone, ...current, from: zone.from, to: zone.to, deadlineAt }] : [];
32
+ if (!current) {
33
+ const stillWanted = file.sourceZones.some(candidate => candidate.from < interval.to && interval.from < candidate.to);
34
+ if (!stillWanted) changed = true;
35
+ return stillWanted ? [zone] : [];
36
+ }
37
+ const deadlineAt = Number.isFinite(current.deadlineAt)
38
+ ? current.deadlineAt + (Math.max(0, zone.from - current.from) - (zone.leadSeconds ?? 0)) * 1000 : current.deadlineAt;
39
+ if (Object.keys(current).some(key => key !== "from" && key !== "to" && !Object.is(zone[key], key === "deadlineAt" ? deadlineAt : current[key]))) changed = true;
40
+ return [{ ...zone, ...current, from: zone.from, to: zone.to, deadlineAt }];
29
41
  });
30
42
  this.#setZones(file, retained);
31
43
  for (const [statement, metadata] of file.metadata) {