@camstack/system 1.2.63 → 1.2.64
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/addon-runner.js +1 -1
- package/dist/addon-runner.mjs +1 -1
- package/dist/addon-utils.js +1 -1
- package/dist/addon-utils.mjs +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +3 -3
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +2 -2
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
- package/dist/builtins/device-manager/device-manager.addon.js +1 -1
- package/dist/builtins/device-manager/device-manager.addon.mjs +1 -1
- package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
- package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
- package/dist/builtins/local-auth/local-auth.addon.js +1 -1
- package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
- package/dist/builtins/local-network/local-network.addon.js +1 -1
- package/dist/builtins/local-network/local-network.addon.mjs +1 -1
- package/dist/builtins/loki-logging/index.js +1 -1
- package/dist/builtins/loki-logging/index.mjs +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.js +2 -2
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +2 -2
- package/dist/builtins/platform-probe/index.js +2 -2
- package/dist/builtins/platform-probe/index.mjs +2 -2
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +1 -1
- package/dist/builtins/snapshot/index.mjs +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +29 -3
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +29 -3
- package/dist/builtins/sqlite-storage/vector-index-vec.d.ts +19 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
- package/dist/builtins/system-config/system-config.addon.js +1 -1
- package/dist/builtins/system-config/system-config.addon.mjs +1 -1
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{dist-BJ2w3uoN.mjs → dist-CMb7aKS0.mjs} +39 -1
- package/dist/{dist-LDrOFrd-.js → dist-DP3nHlNM.js} +39 -1
- package/dist/http/asset-compression.d.ts +93 -0
- package/dist/index.js +63 -9
- package/dist/index.mjs +63 -9
- package/dist/kernel/heap-watch.d.ts +55 -0
- package/dist/{model-download-service-D8B-4ktF.mjs → model-download-service-CTBHzORJ.mjs} +220 -2
- package/dist/{model-download-service-Cpc6SWzV.js → model-download-service-Caj1kaSi.js} +220 -2
- package/package.json +1 -1
- package/dist/{manifest-python-deps-DNfmBu20.js → manifest-python-deps-BE6l8Y4W.js} +1 -1
- package/dist/{manifest-python-deps-CopL1_5C.mjs → manifest-python-deps-Dz1ryHIN.mjs} +1 -1
|
@@ -19525,7 +19525,29 @@ var FaceInfoSchema = z.object({
|
|
|
19525
19525
|
recognizedIdentityId: z.string().optional(),
|
|
19526
19526
|
identityName: z.string().optional(),
|
|
19527
19527
|
assigned: z.boolean(),
|
|
19528
|
+
/**
|
|
19529
|
+
* The crop, inline, base64.
|
|
19530
|
+
*
|
|
19531
|
+
* **Prefer {@link cropUrl}.** At the 500 rows the Faces view asks for this
|
|
19532
|
+
* field alone is ~2.87 MiB, re-sent in full on every operator assign and
|
|
19533
|
+
* every 30 s poll, base64-inflated over the msgpack socket and held in the
|
|
19534
|
+
* query heap. It stays for callers that have not migrated; `includeCrops:
|
|
19535
|
+
* false` turns it off once they have.
|
|
19536
|
+
*/
|
|
19528
19537
|
base64: z.string().optional(),
|
|
19538
|
+
/**
|
|
19539
|
+
* Same crop, as a data-plane URL for `<img src>` — the move the admin
|
|
19540
|
+
* snapshot surfaces made on 2026-08-08.
|
|
19541
|
+
*
|
|
19542
|
+
* Served by the `event-media` plane, which resolves a raw MediaStore key and
|
|
19543
|
+
* is `access: 'authenticated'`: a bare `<img>` carries the `camstack_session`
|
|
19544
|
+
* cookie, so no header plumbing is needed. The bytes then ride the browser's
|
|
19545
|
+
* HTTP cache with an ETag and `immutable`, instead of the WebSocket.
|
|
19546
|
+
*
|
|
19547
|
+
* Absent when the face has no stored crop, or when the addon has no data
|
|
19548
|
+
* plane — callers fall back to {@link base64}.
|
|
19549
|
+
*/
|
|
19550
|
+
cropUrl: z.string().optional(),
|
|
19529
19551
|
/** Design B: the face bbox (pixel space) on the key frame — lets a detail
|
|
19530
19552
|
* view draw the box over the native `keyFrameMediaKey` frame. Absent on
|
|
19531
19553
|
* legacy rows written before design B. */
|
|
@@ -19601,7 +19623,23 @@ var faceGalleryCapability = {
|
|
|
19601
19623
|
}),
|
|
19602
19624
|
listRecentFaces: method(z.object({
|
|
19603
19625
|
limit: z.number().int().positive().optional(),
|
|
19604
|
-
filter: FaceFilterEnum.optional()
|
|
19626
|
+
filter: FaceFilterEnum.optional(),
|
|
19627
|
+
/**
|
|
19628
|
+
* Inline the base64 crop on every row. Default `true` — the existing
|
|
19629
|
+
* behaviour, kept so no caller breaks.
|
|
19630
|
+
*
|
|
19631
|
+
* Set `false` once the caller renders {@link FaceInfo.cropUrl}: that
|
|
19632
|
+
* drops ~2.87 MiB per 500-row page to a few KiB of metadata and lets
|
|
19633
|
+
* the browser cache the images.
|
|
19634
|
+
*
|
|
19635
|
+
* **This is an INPUT field, so it does not reach the addon until the
|
|
19636
|
+
* next train.** The hub router validates cap inputs against its own
|
|
19637
|
+
* compiled Zod, which strips a key it does not know — verified today
|
|
19638
|
+
* on the OUTPUT side, where an additive field DOES arrive immediately
|
|
19639
|
+
* (`Track.hasFace`). Until the train ships, sending `false` is
|
|
19640
|
+
* harmless and simply keeps the crops inline.
|
|
19641
|
+
*/
|
|
19642
|
+
includeCrops: z.boolean().optional()
|
|
19605
19643
|
}).optional(), z.array(FaceInfoSchema).readonly()),
|
|
19606
19644
|
getFaceByTrack: method(z.object({
|
|
19607
19645
|
deviceId: z.number().int(),
|
|
@@ -19525,7 +19525,29 @@ var FaceInfoSchema = zod.z.object({
|
|
|
19525
19525
|
recognizedIdentityId: zod.z.string().optional(),
|
|
19526
19526
|
identityName: zod.z.string().optional(),
|
|
19527
19527
|
assigned: zod.z.boolean(),
|
|
19528
|
+
/**
|
|
19529
|
+
* The crop, inline, base64.
|
|
19530
|
+
*
|
|
19531
|
+
* **Prefer {@link cropUrl}.** At the 500 rows the Faces view asks for this
|
|
19532
|
+
* field alone is ~2.87 MiB, re-sent in full on every operator assign and
|
|
19533
|
+
* every 30 s poll, base64-inflated over the msgpack socket and held in the
|
|
19534
|
+
* query heap. It stays for callers that have not migrated; `includeCrops:
|
|
19535
|
+
* false` turns it off once they have.
|
|
19536
|
+
*/
|
|
19528
19537
|
base64: zod.z.string().optional(),
|
|
19538
|
+
/**
|
|
19539
|
+
* Same crop, as a data-plane URL for `<img src>` — the move the admin
|
|
19540
|
+
* snapshot surfaces made on 2026-08-08.
|
|
19541
|
+
*
|
|
19542
|
+
* Served by the `event-media` plane, which resolves a raw MediaStore key and
|
|
19543
|
+
* is `access: 'authenticated'`: a bare `<img>` carries the `camstack_session`
|
|
19544
|
+
* cookie, so no header plumbing is needed. The bytes then ride the browser's
|
|
19545
|
+
* HTTP cache with an ETag and `immutable`, instead of the WebSocket.
|
|
19546
|
+
*
|
|
19547
|
+
* Absent when the face has no stored crop, or when the addon has no data
|
|
19548
|
+
* plane — callers fall back to {@link base64}.
|
|
19549
|
+
*/
|
|
19550
|
+
cropUrl: zod.z.string().optional(),
|
|
19529
19551
|
/** Design B: the face bbox (pixel space) on the key frame — lets a detail
|
|
19530
19552
|
* view draw the box over the native `keyFrameMediaKey` frame. Absent on
|
|
19531
19553
|
* legacy rows written before design B. */
|
|
@@ -19601,7 +19623,23 @@ var faceGalleryCapability = {
|
|
|
19601
19623
|
}),
|
|
19602
19624
|
listRecentFaces: method(zod.z.object({
|
|
19603
19625
|
limit: zod.z.number().int().positive().optional(),
|
|
19604
|
-
filter: FaceFilterEnum.optional()
|
|
19626
|
+
filter: FaceFilterEnum.optional(),
|
|
19627
|
+
/**
|
|
19628
|
+
* Inline the base64 crop on every row. Default `true` — the existing
|
|
19629
|
+
* behaviour, kept so no caller breaks.
|
|
19630
|
+
*
|
|
19631
|
+
* Set `false` once the caller renders {@link FaceInfo.cropUrl}: that
|
|
19632
|
+
* drops ~2.87 MiB per 500-row page to a few KiB of metadata and lets
|
|
19633
|
+
* the browser cache the images.
|
|
19634
|
+
*
|
|
19635
|
+
* **This is an INPUT field, so it does not reach the addon until the
|
|
19636
|
+
* next train.** The hub router validates cap inputs against its own
|
|
19637
|
+
* compiled Zod, which strips a key it does not know — verified today
|
|
19638
|
+
* on the OUTPUT side, where an additive field DOES arrive immediately
|
|
19639
|
+
* (`Track.hasFace`). Until the train ships, sending `false` is
|
|
19640
|
+
* harmless and simply keeps the crops inline.
|
|
19641
|
+
*/
|
|
19642
|
+
includeCrops: zod.z.boolean().optional()
|
|
19605
19643
|
}).optional(), zod.z.array(FaceInfoSchema).readonly()),
|
|
19606
19644
|
getFaceByTrack: method(zod.z.object({
|
|
19607
19645
|
deviceId: zod.z.number().int(),
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-coding for addon-served static assets: `Accept-Encoding` negotiation,
|
|
3
|
+
* an eligibility rule, and a BOUNDED cache of the compressed bytes.
|
|
4
|
+
*
|
|
5
|
+
* ## Why the ADDON owns the encoding
|
|
6
|
+
*
|
|
7
|
+
* The hub's `/addon/:addonId/*` bridge is registered `{ compress: false }`. That
|
|
8
|
+
* is not an oversight — on 2026-07-16 the global `@fastify/compress` was found
|
|
9
|
+
* emitting an EMPTY brotli stream (`content-encoding: br`, `content-length: 0`)
|
|
10
|
+
* for any compressible body over its 1 KiB threshold once the body had crossed
|
|
11
|
+
* the UDS route bridge of a FORKED addon. It silently broke the notifier SVG
|
|
12
|
+
* icons; the deploy-bundle pull route had already dodged the same class with
|
|
13
|
+
* `compress: false`. The resolution then, and the contract now, is that an addon
|
|
14
|
+
* route owns its content-coding END TO END: it negotiates, it compresses, it
|
|
15
|
+
* stamps `content-encoding` + `Vary`, and the hub pipes those bytes through
|
|
16
|
+
* untouched (`proxyToUpstream` replays the upstream headers verbatim).
|
|
17
|
+
*
|
|
18
|
+
* So compression belongs HERE, in the shared file data-plane, and nowhere above
|
|
19
|
+
* it. Anything that re-compresses on top of this reintroduces the empty-brotli
|
|
20
|
+
* bug rather than doubling the ratio.
|
|
21
|
+
*
|
|
22
|
+
* ## Why the bytes are cached, and why the cache is bounded
|
|
23
|
+
*
|
|
24
|
+
* Measured on the stream-broker embed bundle (`index-<hash>.js`, 1,443,142 B):
|
|
25
|
+
* gzip-6 → 360,868 B, brotli-5 → 327,419 B in 27 ms, brotli-11 → 293,677 B in
|
|
26
|
+
* 2,233 ms. Quality 5 is the request-path choice: 77% off the wire for 27 ms
|
|
27
|
+
* paid ONCE. Quality 11 buys another 2.3% for eighty times the CPU, on a path
|
|
28
|
+
* where a real client is waiting.
|
|
29
|
+
*
|
|
30
|
+
* The cache is an LRU bounded by BOTH entry count and total bytes, and it
|
|
31
|
+
* refuses assets over {@link COMPRESSION_MAX_ASSET_BYTES} outright. An unbounded
|
|
32
|
+
* `Map` keyed by URL is how a long-lived process walks into an OOM, and this
|
|
33
|
+
* repo has already spent a session on one.
|
|
34
|
+
*
|
|
35
|
+
* The key carries `mtimeMs` and `size`, so a rebuild that rewrites an asset in
|
|
36
|
+
* place under an UNCHANGED name (an SPA shell, a service worker) can never be
|
|
37
|
+
* served from the previous build's bytes.
|
|
38
|
+
*/
|
|
39
|
+
/** The codings this layer will produce. Identity is the absence of one. */
|
|
40
|
+
export type ContentEncoding = 'br' | 'gzip';
|
|
41
|
+
/**
|
|
42
|
+
* Below this the framing and the `Vary` cache split cost more than the
|
|
43
|
+
* compression saves. Same threshold the hub's global compressor is registered
|
|
44
|
+
* with, deliberately.
|
|
45
|
+
*/
|
|
46
|
+
export declare const COMPRESSION_MIN_BYTES = 1024;
|
|
47
|
+
/**
|
|
48
|
+
* Above this an asset is streamed identity and never buffered. Compression
|
|
49
|
+
* requires the whole body in RAM twice; a multi-hundred-MB recording segment
|
|
50
|
+
* must not be able to do that, whatever its content type says.
|
|
51
|
+
*/
|
|
52
|
+
export declare const COMPRESSION_MAX_ASSET_BYTES: number;
|
|
53
|
+
/** Is a body of this content type worth compressing at all? */
|
|
54
|
+
export declare function isCompressibleContentType(contentType: string): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Pick a coding for this request: brotli when offered, else gzip, else identity.
|
|
57
|
+
*
|
|
58
|
+
* Returns `null` for identity — including when the header is ABSENT. A client
|
|
59
|
+
* that offers nothing gets the raw bytes; guessing on its behalf is how a
|
|
60
|
+
* non-negotiating consumer (a native fetch, a probe, an OTA puller) ends up with
|
|
61
|
+
* a body it cannot read.
|
|
62
|
+
*/
|
|
63
|
+
export declare function negotiateContentEncoding(acceptEncoding: string | undefined): ContentEncoding | null;
|
|
64
|
+
/** Identity of one compressed body. `mtimeMs`+`size` make a rebuild-in-place a MISS. */
|
|
65
|
+
export interface CompressedAssetKey {
|
|
66
|
+
readonly path: string;
|
|
67
|
+
readonly mtimeMs: number;
|
|
68
|
+
readonly size: number;
|
|
69
|
+
readonly encoding: ContentEncoding;
|
|
70
|
+
}
|
|
71
|
+
/** Counters for the cache. Exported so a test can prove the compressor ran ONCE. */
|
|
72
|
+
export interface AssetCompressionStats {
|
|
73
|
+
/** Bodies actually run through zlib. */
|
|
74
|
+
readonly compressions: number;
|
|
75
|
+
/** Requests served from already-compressed bytes. */
|
|
76
|
+
readonly hits: number;
|
|
77
|
+
readonly entries: number;
|
|
78
|
+
readonly bytes: number;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Compressed bytes for one asset, compressing at most once per key.
|
|
82
|
+
*
|
|
83
|
+
* `null` means "send the original" — the asset is too large to buffer, the
|
|
84
|
+
* source could not be read, or compression did not actually shrink it. Every
|
|
85
|
+
* failure degrades to identity, so this can only ever be an optimisation.
|
|
86
|
+
*
|
|
87
|
+
* Concurrent callers for the same key share ONE compression: a cold cache hit by
|
|
88
|
+
* a dozen parallel asset requests must not run zlib a dozen times.
|
|
89
|
+
*/
|
|
90
|
+
export declare function getCompressedAsset(key: CompressedAssetKey, readSource: () => Promise<Buffer>): Promise<Buffer | null>;
|
|
91
|
+
export declare function getAssetCompressionStats(): AssetCompressionStats;
|
|
92
|
+
/** Test seam — the cache and its counters are process-wide. */
|
|
93
|
+
export declare function resetAssetCompressionCache(): void;
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
const require_chunk = require("./chunk-Cek0wNdY.js");
|
|
3
|
-
const require_dist = require("./dist-
|
|
4
|
-
const require_model_download_service = require("./model-download-service-
|
|
5
|
-
const require_manifest_python_deps = require("./manifest-python-deps-
|
|
3
|
+
const require_dist = require("./dist-DP3nHlNM.js");
|
|
4
|
+
const require_model_download_service = require("./model-download-service-Caj1kaSi.js");
|
|
5
|
+
const require_manifest_python_deps = require("./manifest-python-deps-BE6l8Y4W.js");
|
|
6
6
|
const require_resource_monitor = require("./resource-monitor-DNNomR-i.js");
|
|
7
7
|
const require_builtins_sqlite_storage_filesystem_storage_addon = require("./builtins/sqlite-storage/filesystem-storage.addon.js");
|
|
8
8
|
const require_builtins_sqlite_storage_sqlite_settings_addon = require("./builtins/sqlite-storage/sqlite-settings.addon.js");
|
|
@@ -41,10 +41,10 @@ let node_fs$1 = require_chunk.__toESM(node_fs, 1);
|
|
|
41
41
|
node_fs = require_chunk.__toESM(node_fs);
|
|
42
42
|
let node_path = require("node:path");
|
|
43
43
|
node_path = require_chunk.__toESM(node_path, 1);
|
|
44
|
-
let _camstack_types_addon = require("@camstack/types/addon");
|
|
45
|
-
let node_child_process = require("node:child_process");
|
|
46
44
|
let node_util = require("node:util");
|
|
47
45
|
node_util = require_chunk.__toESM(node_util);
|
|
46
|
+
let _camstack_types_addon = require("@camstack/types/addon");
|
|
47
|
+
let node_child_process = require("node:child_process");
|
|
48
48
|
let node_os = require("node:os");
|
|
49
49
|
node_os = require_chunk.__toESM(node_os);
|
|
50
50
|
let node_fs_promises = require("node:fs/promises");
|
|
@@ -139,6 +139,30 @@ var HEAP_WATCH_INTERVAL_MS = 6e4;
|
|
|
139
139
|
* the fatal by more than one interval under realistic growth. */
|
|
140
140
|
var HEAP_WATCH_WARN_RATIO = .8;
|
|
141
141
|
/**
|
|
142
|
+
* Escalate above this share of the ceiling.
|
|
143
|
+
*
|
|
144
|
+
* 0.70 is 3002MB: clear of the measured routine max (2847MB) so steady state
|
|
145
|
+
* never escalates, and below {@link HEAP_WATCH_WARN_RATIO} so the fast cadence
|
|
146
|
+
* is already running by the time the warn is reachable.
|
|
147
|
+
*/
|
|
148
|
+
var HEAP_WATCH_ESCALATE_RATIO = .7;
|
|
149
|
+
/**
|
|
150
|
+
* Release escalation below this share.
|
|
151
|
+
*
|
|
152
|
+
* Hysteresis, not a second threshold: hub-main's heap sawtooths continuously,
|
|
153
|
+
* so a single ratio would flap the mode on every GC. 0.60 is 2573MB — a real
|
|
154
|
+
* retreat rather than the trough of one collection.
|
|
155
|
+
*/
|
|
156
|
+
var HEAP_WATCH_DEESCALATE_RATIO = .6;
|
|
157
|
+
/**
|
|
158
|
+
* The cadence state machine, pure so the thresholds are testable without
|
|
159
|
+
* allocating three gigabytes to reach them.
|
|
160
|
+
*/
|
|
161
|
+
function nextMode(current, usedRatio, escalateRatio = HEAP_WATCH_ESCALATE_RATIO, deescalateRatio = HEAP_WATCH_DEESCALATE_RATIO) {
|
|
162
|
+
if (current === "escalated") return usedRatio < deescalateRatio ? "steady" : "escalated";
|
|
163
|
+
return usedRatio >= escalateRatio ? "escalated" : "steady";
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
142
166
|
* Reclaim once the process holds this many MB beyond what it is USING.
|
|
143
167
|
*
|
|
144
168
|
* Live calibration, both readings from the same hub minutes apart: 2491MB
|
|
@@ -229,6 +253,9 @@ function startHeapWatch(label = "hub-main", sink = consoleSink, intervalMs = HEA
|
|
|
229
253
|
const now = reclaimOptions?.now ?? (() => Date.now());
|
|
230
254
|
const triggerMb = reclaimOptions?.triggerMb ?? 1536;
|
|
231
255
|
const minIntervalMs = reclaimOptions?.minIntervalMs ?? 6e5;
|
|
256
|
+
const fastIntervalMs = reclaimOptions?.fastIntervalMs ?? 2e3;
|
|
257
|
+
const escalateRatio = reclaimOptions?.escalateRatio ?? .7;
|
|
258
|
+
const deescalateRatio = reclaimOptions?.deescalateRatio ?? .6;
|
|
232
259
|
let lastReclaimAt = Number.NEGATIVE_INFINITY;
|
|
233
260
|
const read = () => {
|
|
234
261
|
const limit = reclaimOptions?.heapLimitBytes ?? node_v8.getHeapStatistics().heap_size_limit;
|
|
@@ -249,16 +276,43 @@ function startHeapWatch(label = "hub-main", sink = consoleSink, intervalMs = HEA
|
|
|
249
276
|
const after = read();
|
|
250
277
|
sink.info(`[mem] reclaim ${label} stranded=${strandedMb(sample)}MB rss=${sample.rssMb}MB→${after.rssMb}MB freed=${sample.rssMb - after.rssMb}MB arrayBuffers=${sample.arrayBuffersMb}MB→${after.arrayBuffersMb}MB took=${now() - startedAt}ms`);
|
|
251
278
|
};
|
|
279
|
+
let mode = "steady";
|
|
280
|
+
let lastLoggedAt = Number.NEGATIVE_INFINITY;
|
|
281
|
+
/**
|
|
282
|
+
* PROBING and LOGGING are separate cadences, and that separation is the whole
|
|
283
|
+
* point.
|
|
284
|
+
*
|
|
285
|
+
* The 2026-08-08 fatal escaped a 60s heartbeat because the burst began and
|
|
286
|
+
* ended between two samples. Deciding "is the heap elevated?" only when a
|
|
287
|
+
* line is due would reproduce that exactly: the process would still be read
|
|
288
|
+
* once a minute, and a 62-second climb would still be invisible. So the timer
|
|
289
|
+
* runs at the FAST period always — `process.memoryUsage()` is tens of
|
|
290
|
+
* microseconds, i.e. free at this rate — and the slow period governs only how
|
|
291
|
+
* often a steady process is allowed to say so.
|
|
292
|
+
*
|
|
293
|
+
* Never probe slower than the log cadence, or a caller passing a short
|
|
294
|
+
* `intervalMs` would silently get the long one.
|
|
295
|
+
*/
|
|
296
|
+
const probeIntervalMs = Math.min(fastIntervalMs, intervalMs);
|
|
252
297
|
const tick = () => {
|
|
253
298
|
try {
|
|
254
299
|
const sample = read();
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
300
|
+
const previous = mode;
|
|
301
|
+
mode = nextMode(previous, sample.usedRatio, escalateRatio, deescalateRatio);
|
|
302
|
+
const at = now();
|
|
303
|
+
const due = at - lastLoggedAt >= intervalMs;
|
|
304
|
+
if (mode === "escalated" || due) {
|
|
305
|
+
lastLoggedAt = at;
|
|
306
|
+
const line = format$1(label, sample);
|
|
307
|
+
if (sample.nearLimit) sink.warn(`${line} — APPROACHING HEAP LIMIT`);
|
|
308
|
+
else if (mode === "escalated") sink.warn(`${line} — heap elevated, sampling every ${probeIntervalMs}ms`);
|
|
309
|
+
else sink.info(line);
|
|
310
|
+
}
|
|
311
|
+
if (previous === "escalated" && mode === "steady") sink.info(`[mem] ${label} heap back to routine — logging every ${intervalMs}ms`);
|
|
258
312
|
maybeReclaim(sample);
|
|
259
313
|
} catch {}
|
|
260
314
|
};
|
|
261
|
-
const timer = setInterval(tick,
|
|
315
|
+
const timer = setInterval(tick, probeIntervalMs);
|
|
262
316
|
timer.unref?.();
|
|
263
317
|
tick();
|
|
264
318
|
let stopped = false;
|
package/dist/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { a as __toCommonJS, i as __require, n as __esmMin, o as __toESM$1, r as __exportAll, t as __commonJSMin$1 } from "./chunk-CNf5ZN-e.mjs";
|
|
2
|
-
import { Ct as asString$1, Dt as expandCapMethods, F as extractNestedAddonId, Ft as scopeKey, H as kebabToCamel, K as logLevelAtMost, L as isArrayOutputSchema, Lt as EventCategory$1, Mt as parseJsonUnknown$1, Nt as readinessKey, Pt as resolveCapMount, R as isCollectionArrayMethod, St as asNumber, Tt as emitDownForOwnedCaps, U as lifecycleJobSchema, V as isVoidInput, Z as objectInputDeclaresAddonId, et as procedureAuthKey, ft as DATAPLANE_SECRET_HEADER$1, g as addonSettingsCapability, jt as parseJsonObject, l as METHOD_ACCESS_MAP, mt as DEVICE_STATUS_METHOD, pt as DEVICE_SETTINGS_CONTRIBUTION_METHODS, q as looseSchema, t as ALL_CAPABILITY_DEFINITIONS, u as RUNTIME_DEFAULTS, ut as errMsg$1, vt as ReadinessRegistry, wt as createEvent, xt as asJsonObject$1, yt as ReadinessTimeoutError, z as isObjectInput } from "./dist-
|
|
3
|
-
import { a as downloadModel, c as getModelFilePath, d as contentTypeFor, f as createAuthenticatedFileServer, h as resolveFilePath, i as downloadFile, l as isModelDownloaded, m as parseTokenizedUrl, n as collectModelFiles, o as ensureModel, p as parseRangeHeader, r as deleteModelFromDisk, s as fetchJson, t as ModelDownloadService, u as createFileDataPlaneHandler } from "./model-download-service-
|
|
4
|
-
import { $ as buildNativeCapProxy, A as createHubCapForwardService, B as AGENT_CAP_FWD_ACTION, C as brokerTransportLink, Ct as resolveAddonClass, D as localProviderLink, E as ipcParentLink, F as createUdsLogger, G as callWithServiceDiscovery, H as CapRouteResolver, I as createUdsLoggerWithControl, J as UdsLocalTransportServer, K as createLocalTransport, L as LocalChildClient, M as createUdsEventBridge, N as createUdsEventBus, O as HUB_CAP_FWD_ACTION, P as udsChildLogToWorkerEntry, Q as encodeFrame, R as LocalChildRegistry, S as brokerCallForCap, St as runNpm, T as ipcChildLink, U as CapRouteError, V as AGENT_CAP_FWD_SERVICE, W as classifyCapRoute, X as localEndpointPath, Y as SocketChannel, Z as FrameDecoder, _ as createKernelHwAccel, _t as CapabilityHandle, a as getWorkerDeviceRegistry, b as __resetCapUsageRegistryForTests, bt as installManifestNativeDeps, c as setHubConnected, ct as NATIVE_PROVIDER_SERVICE_INFIX, d as getBrokerEventBus, dt as capBareAction, et as buildUdsNativeCapProxy, f as getMoleculerEventStats, ft as capServiceName, g as AddonDepsManager, gt as DeviceRegistry, h as subscribePassthrough, ht as serializeTypedArrays, i as createUdsAddonContext, it as mountNativeCapService, j as createParentUnownedCallHandler, k as HUB_CAP_FWD_SERVICE, l as EVENT_TOPIC_PREFIX, lt as capActionName, m as setNodeEventInterest, mt as deserializeTypedArrays, n as adaptBrokerToCluster, o as getOrInitReadinessRegistry, ot as createAddonService, p as registerEventBusService, pt as parseCapAction, q as UdsLocalTransportClient, r as createAddonContext, s as getOrInitReadinessRegistryForClient, st as validateProviderRegistrations, t as installManifestPythonDeps, tt as createBrokerDeviceManagerApi, u as clusterEventTopic, ut as capActionSuffix, v as resolveHwAccel, vt as CapabilityUnavailableError, w as buildLinkChain, wt as createAddonDataPlaneFacility, x as getCapUsageRegistry, xt as resolveNpmInvocation, y as CapUsageRegistry, yt as copyBundledNativeModules, z as UDS_NO_ROUTE_PREFIX } from "./manifest-python-deps-
|
|
2
|
+
import { Ct as asString$1, Dt as expandCapMethods, F as extractNestedAddonId, Ft as scopeKey, H as kebabToCamel, K as logLevelAtMost, L as isArrayOutputSchema, Lt as EventCategory$1, Mt as parseJsonUnknown$1, Nt as readinessKey, Pt as resolveCapMount, R as isCollectionArrayMethod, St as asNumber, Tt as emitDownForOwnedCaps, U as lifecycleJobSchema, V as isVoidInput, Z as objectInputDeclaresAddonId, et as procedureAuthKey, ft as DATAPLANE_SECRET_HEADER$1, g as addonSettingsCapability, jt as parseJsonObject, l as METHOD_ACCESS_MAP, mt as DEVICE_STATUS_METHOD, pt as DEVICE_SETTINGS_CONTRIBUTION_METHODS, q as looseSchema, t as ALL_CAPABILITY_DEFINITIONS, u as RUNTIME_DEFAULTS, ut as errMsg$1, vt as ReadinessRegistry, wt as createEvent, xt as asJsonObject$1, yt as ReadinessTimeoutError, z as isObjectInput } from "./dist-CMb7aKS0.mjs";
|
|
3
|
+
import { a as downloadModel, c as getModelFilePath, d as contentTypeFor, f as createAuthenticatedFileServer, h as resolveFilePath, i as downloadFile, l as isModelDownloaded, m as parseTokenizedUrl, n as collectModelFiles, o as ensureModel, p as parseRangeHeader, r as deleteModelFromDisk, s as fetchJson, t as ModelDownloadService, u as createFileDataPlaneHandler } from "./model-download-service-CTBHzORJ.mjs";
|
|
4
|
+
import { $ as buildNativeCapProxy, A as createHubCapForwardService, B as AGENT_CAP_FWD_ACTION, C as brokerTransportLink, Ct as resolveAddonClass, D as localProviderLink, E as ipcParentLink, F as createUdsLogger, G as callWithServiceDiscovery, H as CapRouteResolver, I as createUdsLoggerWithControl, J as UdsLocalTransportServer, K as createLocalTransport, L as LocalChildClient, M as createUdsEventBridge, N as createUdsEventBus, O as HUB_CAP_FWD_ACTION, P as udsChildLogToWorkerEntry, Q as encodeFrame, R as LocalChildRegistry, S as brokerCallForCap, St as runNpm, T as ipcChildLink, U as CapRouteError, V as AGENT_CAP_FWD_SERVICE, W as classifyCapRoute, X as localEndpointPath, Y as SocketChannel, Z as FrameDecoder, _ as createKernelHwAccel, _t as CapabilityHandle, a as getWorkerDeviceRegistry, b as __resetCapUsageRegistryForTests, bt as installManifestNativeDeps, c as setHubConnected, ct as NATIVE_PROVIDER_SERVICE_INFIX, d as getBrokerEventBus, dt as capBareAction, et as buildUdsNativeCapProxy, f as getMoleculerEventStats, ft as capServiceName, g as AddonDepsManager, gt as DeviceRegistry, h as subscribePassthrough, ht as serializeTypedArrays, i as createUdsAddonContext, it as mountNativeCapService, j as createParentUnownedCallHandler, k as HUB_CAP_FWD_SERVICE, l as EVENT_TOPIC_PREFIX, lt as capActionName, m as setNodeEventInterest, mt as deserializeTypedArrays, n as adaptBrokerToCluster, o as getOrInitReadinessRegistry, ot as createAddonService, p as registerEventBusService, pt as parseCapAction, q as UdsLocalTransportClient, r as createAddonContext, s as getOrInitReadinessRegistryForClient, st as validateProviderRegistrations, t as installManifestPythonDeps, tt as createBrokerDeviceManagerApi, u as clusterEventTopic, ut as capActionSuffix, v as resolveHwAccel, vt as CapabilityUnavailableError, w as buildLinkChain, wt as createAddonDataPlaneFacility, x as getCapUsageRegistry, xt as resolveNpmInvocation, y as CapUsageRegistry, yt as copyBundledNativeModules, z as UDS_NO_ROUTE_PREFIX } from "./manifest-python-deps-Dz1ryHIN.mjs";
|
|
5
5
|
import { n as getSinglePidStats, t as getPidStats } from "./resource-monitor-BkP504Vq.mjs";
|
|
6
6
|
import { FilesystemStorageAddon, t as FilesystemStorageProvider } from "./builtins/sqlite-storage/filesystem-storage.addon.mjs";
|
|
7
7
|
import { SqliteSettingsAddon, o as SqliteSettingsBackend } from "./builtins/sqlite-storage/sqlite-settings.addon.mjs";
|
|
@@ -37,10 +37,10 @@ import * as fs$17 from "node:fs";
|
|
|
37
37
|
import { accessSync, constants, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
38
38
|
import * as path$39 from "node:path";
|
|
39
39
|
import { dirname, isAbsolute, join, posix, resolve, win32 } from "node:path";
|
|
40
|
-
import { asJsonObject, errMsg, parseJsonUnknown } from "@camstack/types/addon";
|
|
41
|
-
import { execFile, spawn } from "node:child_process";
|
|
42
40
|
import * as util$10 from "node:util";
|
|
43
41
|
import { promisify } from "node:util";
|
|
42
|
+
import { asJsonObject, errMsg, parseJsonUnknown } from "@camstack/types/addon";
|
|
43
|
+
import { execFile, spawn } from "node:child_process";
|
|
44
44
|
import * as os$18 from "node:os";
|
|
45
45
|
import { lstat, readdir, readlink, realpath } from "node:fs/promises";
|
|
46
46
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -131,6 +131,30 @@ var HEAP_WATCH_INTERVAL_MS = 6e4;
|
|
|
131
131
|
* the fatal by more than one interval under realistic growth. */
|
|
132
132
|
var HEAP_WATCH_WARN_RATIO = .8;
|
|
133
133
|
/**
|
|
134
|
+
* Escalate above this share of the ceiling.
|
|
135
|
+
*
|
|
136
|
+
* 0.70 is 3002MB: clear of the measured routine max (2847MB) so steady state
|
|
137
|
+
* never escalates, and below {@link HEAP_WATCH_WARN_RATIO} so the fast cadence
|
|
138
|
+
* is already running by the time the warn is reachable.
|
|
139
|
+
*/
|
|
140
|
+
var HEAP_WATCH_ESCALATE_RATIO = .7;
|
|
141
|
+
/**
|
|
142
|
+
* Release escalation below this share.
|
|
143
|
+
*
|
|
144
|
+
* Hysteresis, not a second threshold: hub-main's heap sawtooths continuously,
|
|
145
|
+
* so a single ratio would flap the mode on every GC. 0.60 is 2573MB — a real
|
|
146
|
+
* retreat rather than the trough of one collection.
|
|
147
|
+
*/
|
|
148
|
+
var HEAP_WATCH_DEESCALATE_RATIO = .6;
|
|
149
|
+
/**
|
|
150
|
+
* The cadence state machine, pure so the thresholds are testable without
|
|
151
|
+
* allocating three gigabytes to reach them.
|
|
152
|
+
*/
|
|
153
|
+
function nextMode(current, usedRatio, escalateRatio = HEAP_WATCH_ESCALATE_RATIO, deescalateRatio = HEAP_WATCH_DEESCALATE_RATIO) {
|
|
154
|
+
if (current === "escalated") return usedRatio < deescalateRatio ? "steady" : "escalated";
|
|
155
|
+
return usedRatio >= escalateRatio ? "escalated" : "steady";
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
134
158
|
* Reclaim once the process holds this many MB beyond what it is USING.
|
|
135
159
|
*
|
|
136
160
|
* Live calibration, both readings from the same hub minutes apart: 2491MB
|
|
@@ -221,6 +245,9 @@ function startHeapWatch(label = "hub-main", sink = consoleSink, intervalMs = HEA
|
|
|
221
245
|
const now = reclaimOptions?.now ?? (() => Date.now());
|
|
222
246
|
const triggerMb = reclaimOptions?.triggerMb ?? 1536;
|
|
223
247
|
const minIntervalMs = reclaimOptions?.minIntervalMs ?? 6e5;
|
|
248
|
+
const fastIntervalMs = reclaimOptions?.fastIntervalMs ?? 2e3;
|
|
249
|
+
const escalateRatio = reclaimOptions?.escalateRatio ?? .7;
|
|
250
|
+
const deescalateRatio = reclaimOptions?.deescalateRatio ?? .6;
|
|
224
251
|
let lastReclaimAt = Number.NEGATIVE_INFINITY;
|
|
225
252
|
const read = () => {
|
|
226
253
|
const limit = reclaimOptions?.heapLimitBytes ?? v8.getHeapStatistics().heap_size_limit;
|
|
@@ -241,16 +268,43 @@ function startHeapWatch(label = "hub-main", sink = consoleSink, intervalMs = HEA
|
|
|
241
268
|
const after = read();
|
|
242
269
|
sink.info(`[mem] reclaim ${label} stranded=${strandedMb(sample)}MB rss=${sample.rssMb}MB→${after.rssMb}MB freed=${sample.rssMb - after.rssMb}MB arrayBuffers=${sample.arrayBuffersMb}MB→${after.arrayBuffersMb}MB took=${now() - startedAt}ms`);
|
|
243
270
|
};
|
|
271
|
+
let mode = "steady";
|
|
272
|
+
let lastLoggedAt = Number.NEGATIVE_INFINITY;
|
|
273
|
+
/**
|
|
274
|
+
* PROBING and LOGGING are separate cadences, and that separation is the whole
|
|
275
|
+
* point.
|
|
276
|
+
*
|
|
277
|
+
* The 2026-08-08 fatal escaped a 60s heartbeat because the burst began and
|
|
278
|
+
* ended between two samples. Deciding "is the heap elevated?" only when a
|
|
279
|
+
* line is due would reproduce that exactly: the process would still be read
|
|
280
|
+
* once a minute, and a 62-second climb would still be invisible. So the timer
|
|
281
|
+
* runs at the FAST period always — `process.memoryUsage()` is tens of
|
|
282
|
+
* microseconds, i.e. free at this rate — and the slow period governs only how
|
|
283
|
+
* often a steady process is allowed to say so.
|
|
284
|
+
*
|
|
285
|
+
* Never probe slower than the log cadence, or a caller passing a short
|
|
286
|
+
* `intervalMs` would silently get the long one.
|
|
287
|
+
*/
|
|
288
|
+
const probeIntervalMs = Math.min(fastIntervalMs, intervalMs);
|
|
244
289
|
const tick = () => {
|
|
245
290
|
try {
|
|
246
291
|
const sample = read();
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
292
|
+
const previous = mode;
|
|
293
|
+
mode = nextMode(previous, sample.usedRatio, escalateRatio, deescalateRatio);
|
|
294
|
+
const at = now();
|
|
295
|
+
const due = at - lastLoggedAt >= intervalMs;
|
|
296
|
+
if (mode === "escalated" || due) {
|
|
297
|
+
lastLoggedAt = at;
|
|
298
|
+
const line = format$1(label, sample);
|
|
299
|
+
if (sample.nearLimit) sink.warn(`${line} — APPROACHING HEAP LIMIT`);
|
|
300
|
+
else if (mode === "escalated") sink.warn(`${line} — heap elevated, sampling every ${probeIntervalMs}ms`);
|
|
301
|
+
else sink.info(line);
|
|
302
|
+
}
|
|
303
|
+
if (previous === "escalated" && mode === "steady") sink.info(`[mem] ${label} heap back to routine — logging every ${intervalMs}ms`);
|
|
250
304
|
maybeReclaim(sample);
|
|
251
305
|
} catch {}
|
|
252
306
|
};
|
|
253
|
-
const timer = setInterval(tick,
|
|
307
|
+
const timer = setInterval(tick, probeIntervalMs);
|
|
254
308
|
timer.unref?.();
|
|
255
309
|
tick();
|
|
256
310
|
let stopped = false;
|
|
@@ -4,6 +4,55 @@ export declare const HEAP_WATCH_INTERVAL_MS = 60000;
|
|
|
4
4
|
/** Warn above this share of the heap ceiling. Chosen so the warning precedes
|
|
5
5
|
* the fatal by more than one interval under realistic growth. */
|
|
6
6
|
export declare const HEAP_WATCH_WARN_RATIO = 0.8;
|
|
7
|
+
/**
|
|
8
|
+
* ## The heartbeat could not see the thing it was built to see
|
|
9
|
+
*
|
|
10
|
+
* 2026-08-08 18:28:32Z, hub-main (pid 8) died on `Reached heap limit`. The last
|
|
11
|
+
* `[mem]` line was 18:27:30.433 reading `heapUsed=1086MB` — 25%, unremarkable —
|
|
12
|
+
* and V8's own trace 62 seconds later read 4083MB with a Mark-Compact that
|
|
13
|
+
* reclaimed 4MB of 4090MB. Between those two facts there is not one sample.
|
|
14
|
+
*
|
|
15
|
+
* At `HEAP_WATCH_INTERVAL_MS` the whole event fits inside a single gap, so
|
|
16
|
+
* `HEAP_WATCH_WARN_RATIO` never had a reading to fire on. The docblock above
|
|
17
|
+
* promises "the line that will exist BEFORE the next fatal instead of after
|
|
18
|
+
* it"; on the only occasion that mattered, it did not exist.
|
|
19
|
+
*
|
|
20
|
+
* Measured the same day over 397 consecutive live samples: p50 1266MB, p95
|
|
21
|
+
* 1995MB, **max 2847MB** — 0.66 of the 4288MB ceiling. So the routine sawtooth
|
|
22
|
+
* never approaches 0.8, and the only path to 0.8 is a burst that outruns the
|
|
23
|
+
* sampler. A threshold reachable only through a blind spot is decoration.
|
|
24
|
+
*
|
|
25
|
+
* The fix is not a faster heartbeat — 30 lines a minute forever, to describe a
|
|
26
|
+
* process that is fine, is how a log stops being read. It is to sample fast
|
|
27
|
+
* only while the heap is somewhere the routine sawtooth does not go.
|
|
28
|
+
*/
|
|
29
|
+
/** Sampling period once escalated. Bounded below by log volume, above by the
|
|
30
|
+
* 62s window the 2026-08-08 fatal hid in: ~30 readings across that window is
|
|
31
|
+
* a trajectory, and a trajectory separates a burst from a climb. */
|
|
32
|
+
export declare const HEAP_WATCH_FAST_INTERVAL_MS = 2000;
|
|
33
|
+
/**
|
|
34
|
+
* Escalate above this share of the ceiling.
|
|
35
|
+
*
|
|
36
|
+
* 0.70 is 3002MB: clear of the measured routine max (2847MB) so steady state
|
|
37
|
+
* never escalates, and below {@link HEAP_WATCH_WARN_RATIO} so the fast cadence
|
|
38
|
+
* is already running by the time the warn is reachable.
|
|
39
|
+
*/
|
|
40
|
+
export declare const HEAP_WATCH_ESCALATE_RATIO = 0.7;
|
|
41
|
+
/**
|
|
42
|
+
* Release escalation below this share.
|
|
43
|
+
*
|
|
44
|
+
* Hysteresis, not a second threshold: hub-main's heap sawtooths continuously,
|
|
45
|
+
* so a single ratio would flap the mode on every GC. 0.60 is 2573MB — a real
|
|
46
|
+
* retreat rather than the trough of one collection.
|
|
47
|
+
*/
|
|
48
|
+
export declare const HEAP_WATCH_DEESCALATE_RATIO = 0.6;
|
|
49
|
+
/** Sampling cadence. `escalated` costs log lines and buys resolution. */
|
|
50
|
+
export type HeapWatchMode = 'steady' | 'escalated';
|
|
51
|
+
/**
|
|
52
|
+
* The cadence state machine, pure so the thresholds are testable without
|
|
53
|
+
* allocating three gigabytes to reach them.
|
|
54
|
+
*/
|
|
55
|
+
export declare function nextMode(current: HeapWatchMode, usedRatio: number, escalateRatio?: number, deescalateRatio?: number): HeapWatchMode;
|
|
7
56
|
/**
|
|
8
57
|
* Reclaim once the process holds this many MB beyond what it is USING.
|
|
9
58
|
*
|
|
@@ -73,6 +122,12 @@ export interface HeapReclaimOptions {
|
|
|
73
122
|
readonly triggerMb?: number;
|
|
74
123
|
readonly minIntervalMs?: number;
|
|
75
124
|
readonly now?: () => number;
|
|
125
|
+
/** Sampling cadence once escalated. See {@link HEAP_WATCH_FAST_INTERVAL_MS}. */
|
|
126
|
+
readonly fastIntervalMs?: number;
|
|
127
|
+
/** Ratio at which the fast cadence begins. See {@link HEAP_WATCH_ESCALATE_RATIO}. */
|
|
128
|
+
readonly escalateRatio?: number;
|
|
129
|
+
/** Ratio at which it releases. See {@link HEAP_WATCH_DEESCALATE_RATIO}. */
|
|
130
|
+
readonly deescalateRatio?: number;
|
|
76
131
|
}
|
|
77
132
|
/**
|
|
78
133
|
* Start the heartbeat. Returns a stop function.
|