@mlx-node/server 0.0.7 → 0.0.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/dist/auth.d.ts +56 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +106 -0
- package/dist/chat-session-warm-reuse.d.ts +51 -0
- package/dist/chat-session-warm-reuse.d.ts.map +1 -0
- package/dist/chat-session-warm-reuse.js +72 -0
- package/dist/endpoints/messages-count-tokens.d.ts +8 -0
- package/dist/endpoints/messages-count-tokens.d.ts.map +1 -0
- package/dist/endpoints/messages-count-tokens.js +121 -0
- package/dist/endpoints/messages.d.ts +57 -5
- package/dist/endpoints/messages.d.ts.map +1 -1
- package/dist/endpoints/messages.js +1043 -147
- package/dist/endpoints/models.d.ts +2 -1
- package/dist/endpoints/models.d.ts.map +1 -1
- package/dist/endpoints/models.js +2 -2
- package/dist/endpoints/responses.d.ts +22 -7
- package/dist/endpoints/responses.d.ts.map +1 -1
- package/dist/endpoints/responses.js +608 -85
- package/dist/errors.d.ts +1 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -0
- package/dist/handler.d.ts +69 -1
- package/dist/handler.d.ts.map +1 -1
- package/dist/handler.js +70 -16
- package/dist/health.d.ts +146 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +107 -0
- package/dist/host/discover.d.ts +19 -0
- package/dist/host/discover.d.ts.map +1 -0
- package/dist/host/discover.js +50 -0
- package/dist/host/env-policy.d.ts +62 -0
- package/dist/host/env-policy.d.ts.map +1 -0
- package/dist/host/env-policy.js +69 -0
- package/dist/host/index.d.ts +202 -0
- package/dist/host/index.d.ts.map +1 -0
- package/dist/host/index.js +325 -0
- package/dist/host/logger.d.ts +36 -0
- package/dist/host/logger.d.ts.map +1 -0
- package/dist/host/logger.js +376 -0
- package/dist/host/net.d.ts +65 -0
- package/dist/host/net.d.ts.map +1 -0
- package/dist/host/net.js +97 -0
- package/dist/host/paths.d.ts +28 -0
- package/dist/host/paths.d.ts.map +1 -0
- package/dist/host/paths.js +71 -0
- package/dist/host/swap.d.ts +27 -0
- package/dist/host/swap.d.ts.map +1 -0
- package/dist/host/swap.js +178 -0
- package/dist/host/temp-root.d.ts +57 -0
- package/dist/host/temp-root.d.ts.map +1 -0
- package/dist/host/temp-root.js +99 -0
- package/dist/idle-sweeper.d.ts +245 -0
- package/dist/idle-sweeper.d.ts.map +1 -0
- package/dist/idle-sweeper.js +408 -0
- package/dist/index.d.ts +19 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -1
- package/dist/load-model.d.ts +69 -0
- package/dist/load-model.d.ts.map +1 -0
- package/dist/load-model.js +63 -0
- package/dist/mappers/anthropic-request.d.ts +24 -2
- package/dist/mappers/anthropic-request.d.ts.map +1 -1
- package/dist/mappers/anthropic-request.js +222 -24
- package/dist/mappers/anthropic-response.d.ts +29 -4
- package/dist/mappers/anthropic-response.d.ts.map +1 -1
- package/dist/mappers/anthropic-response.js +143 -21
- package/dist/mappers/request.d.ts +48 -0
- package/dist/mappers/request.d.ts.map +1 -1
- package/dist/mappers/request.js +211 -35
- package/dist/mappers/response.d.ts.map +1 -1
- package/dist/mappers/response.js +13 -1
- package/dist/model-work-coordinator.d.ts +95 -0
- package/dist/model-work-coordinator.d.ts.map +1 -0
- package/dist/model-work-coordinator.js +201 -0
- package/dist/pending-writes.d.ts.map +1 -1
- package/dist/presets.d.ts +82 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +98 -0
- package/dist/registry.d.ts +31 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +33 -5
- package/dist/router.d.ts +37 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +79 -7
- package/dist/server.d.ts +193 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +173 -10
- package/dist/session-registry.d.ts +279 -18
- package/dist/session-registry.d.ts.map +1 -1
- package/dist/session-registry.js +518 -37
- package/dist/stop-sequence-buffer.d.ts +58 -0
- package/dist/stop-sequence-buffer.d.ts.map +1 -0
- package/dist/stop-sequence-buffer.js +148 -0
- package/dist/streaming.d.ts +14 -0
- package/dist/streaming.d.ts.map +1 -1
- package/dist/streaming.js +45 -0
- package/dist/text-recovery.d.ts +35 -0
- package/dist/text-recovery.d.ts.map +1 -0
- package/dist/text-recovery.js +41 -0
- package/dist/timing.d.ts +80 -0
- package/dist/timing.d.ts.map +1 -0
- package/dist/timing.js +121 -0
- package/dist/tool-call-buffer.d.ts +5 -5
- package/dist/tool-call-buffer.d.ts.map +1 -1
- package/dist/tool-call-buffer.js +28 -8
- package/dist/types-anthropic.d.ts +161 -1
- package/dist/types-anthropic.d.ts.map +1 -1
- package/dist/types.d.ts +172 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +15 -3
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-resident lazy-load policy for an inference host.
|
|
3
|
+
*
|
|
4
|
+
* The host discovers every local model up-front but loads at most one into
|
|
5
|
+
* the `ModelRegistry` at a time. Switching models (e.g. via Claude Code's
|
|
6
|
+
* `/model` picker, or the desktop app's model menu) unregisters the previous
|
|
7
|
+
* instance, letting GC + native destructors reclaim memory, before loading
|
|
8
|
+
* the new one.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Build the `resolveModel` + `listModels` callbacks for the handler.
|
|
12
|
+
*
|
|
13
|
+
* `loadModelFn` is injected so tests can stub it without touching native code.
|
|
14
|
+
* The controller serializes every `resolveModel` invocation on a single
|
|
15
|
+
* promise chain so two concurrent requests for different-but-currently-
|
|
16
|
+
* unloaded models cannot race on the native compiled-path globals.
|
|
17
|
+
*/
|
|
18
|
+
export function makeSwapController(discovered, registry, loadModelFn, defaultName) {
|
|
19
|
+
const byName = new Map();
|
|
20
|
+
for (const entry of discovered)
|
|
21
|
+
byName.set(entry.name, entry);
|
|
22
|
+
const ordered = [...discovered].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
23
|
+
// Which entry unknown names (haiku subagent dispatches, etc.) fall back
|
|
24
|
+
// to before anything is resident. Defaults to discovered[0], but the
|
|
25
|
+
// caller can pin it to the user's `--model` pick so the first haiku
|
|
26
|
+
// title-gen doesn't trigger a load of the alphabetically-first model
|
|
27
|
+
// followed by an immediate swap to the user's real choice.
|
|
28
|
+
const fallbackEntry = (defaultName != null ? byName.get(defaultName) : undefined) ?? discovered[0];
|
|
29
|
+
let resident = null;
|
|
30
|
+
// Names we registered as aliases to the current resident (e.g.
|
|
31
|
+
// Claude Code's hardcoded `claude-haiku-*` for subagent dispatches /
|
|
32
|
+
// title generation). Tracked so we can unregister them on `/model`
|
|
33
|
+
// swap — otherwise an alias's refcount would keep the old binding
|
|
34
|
+
// alive past the user's swap.
|
|
35
|
+
const aliases = new Set();
|
|
36
|
+
let currentOp = Promise.resolve();
|
|
37
|
+
async function resolveModel(name) {
|
|
38
|
+
// Fast path: already registered under this name (either as a real
|
|
39
|
+
// resident or as an alias we previously installed). Avoid chaining.
|
|
40
|
+
if (registry.get(name))
|
|
41
|
+
return;
|
|
42
|
+
const next = currentOp.then(async () => {
|
|
43
|
+
// Re-check under the serialized section — a prior waiter may have loaded it.
|
|
44
|
+
if (registry.get(name))
|
|
45
|
+
return;
|
|
46
|
+
// Pick the discovered entry to resolve against. If the requested
|
|
47
|
+
// name matches a discovered model, use it. Otherwise (unknown
|
|
48
|
+
// name — Claude Code's hardcoded small-fast-model, etc.) fall
|
|
49
|
+
// through to the current resident so subagent dispatches don't
|
|
50
|
+
// 404, loading discovered[0] on first boot if nothing is resident
|
|
51
|
+
// yet.
|
|
52
|
+
//
|
|
53
|
+
// CRITICAL: this must read `resident` at RUN time, not QUEUE time.
|
|
54
|
+
// If we capture it before chaining onto `currentOp`, a swap that
|
|
55
|
+
// ran ahead of us will leave us with a stale target — e.g. a haiku
|
|
56
|
+
// alias request that arrived during a `/model a → b` switch would
|
|
57
|
+
// capture `targetEntry = a`, then re-bind itself to `a` and undo
|
|
58
|
+
// the user's switch when its turn finally comes around.
|
|
59
|
+
const knownEntry = byName.get(name);
|
|
60
|
+
const targetEntry = knownEntry ?? (resident ? (byName.get(resident.name) ?? fallbackEntry) : fallbackEntry);
|
|
61
|
+
const isAlias = targetEntry.name !== name;
|
|
62
|
+
// Swap out any stale resident that isn't the target.
|
|
63
|
+
//
|
|
64
|
+
// We do NOT unregister the aliases here: in-flight messages.ts
|
|
65
|
+
// requests may be microtask-racing between "resolveModel returned"
|
|
66
|
+
// and "registry.get(body.model)" and dropping the alias in that
|
|
67
|
+
// window yields a spurious 404. Instead we carry the alias set
|
|
68
|
+
// across the swap and re-point them to the new resident below,
|
|
69
|
+
// so the name always resolves to *some* live instance.
|
|
70
|
+
const oldResident = resident;
|
|
71
|
+
const carriedAliases = new Set(aliases);
|
|
72
|
+
if (oldResident && oldResident.name !== targetEntry.name) {
|
|
73
|
+
// Drop our local alias bookkeeping AND the old resident's primary
|
|
74
|
+
// name binding, but leave the alias *names* in the registry pointed
|
|
75
|
+
// at the old model — they hold the only refcount preventing GC,
|
|
76
|
+
// and an in-flight `registry.get(alias)` must keep resolving to
|
|
77
|
+
// *some* live instance until the new model is in hand.
|
|
78
|
+
aliases.clear();
|
|
79
|
+
registry.unregister(oldResident.name);
|
|
80
|
+
resident = null;
|
|
81
|
+
}
|
|
82
|
+
// Ensure the target is resident. If the load throws, restore the
|
|
83
|
+
// pre-swap controller state so future swap attempts know about the
|
|
84
|
+
// aliases we just cleared — otherwise the alias *names* stay bound
|
|
85
|
+
// in the registry to the old model object forever (alias bindings
|
|
86
|
+
// hold their own refcount), but the controller forgets they exist
|
|
87
|
+
// and never repoints them, leaving alias-routed traffic permanently
|
|
88
|
+
// pinned to a stale model.
|
|
89
|
+
let instance = registry.get(targetEntry.name);
|
|
90
|
+
if (!instance) {
|
|
91
|
+
let loaded;
|
|
92
|
+
try {
|
|
93
|
+
loaded = await loadModelFn(targetEntry.path);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
// Recovery: re-populate the controller's alias set so the next
|
|
97
|
+
// resolveModel call still owns them. The alias→old-model bindings
|
|
98
|
+
// are still live in the registry (we never unregistered them), so
|
|
99
|
+
// we can recover the old model object via any surviving alias and
|
|
100
|
+
// re-bind the old resident's primary name.
|
|
101
|
+
//
|
|
102
|
+
// If `carriedAliases` is empty (no aliases ever existed) AND we
|
|
103
|
+
// unregistered `oldResident.name`, the binding's refcount may have
|
|
104
|
+
// hit zero and the model is gone. There's nothing the controller
|
|
105
|
+
// can do to recover in that case — the user will need to /model-
|
|
106
|
+
// pick again. This is acceptable: alias-less load failures are
|
|
107
|
+
// rare, and the user-facing symptom is "prior model gone, please
|
|
108
|
+
// re-pick", not silently-wrong responses.
|
|
109
|
+
for (const aliasName of carriedAliases)
|
|
110
|
+
aliases.add(aliasName);
|
|
111
|
+
if (oldResident) {
|
|
112
|
+
let oldInstance;
|
|
113
|
+
for (const aliasName of carriedAliases) {
|
|
114
|
+
const probe = registry.get(aliasName);
|
|
115
|
+
if (probe) {
|
|
116
|
+
oldInstance = probe;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (oldInstance) {
|
|
121
|
+
const oldEntry = byName.get(oldResident.name);
|
|
122
|
+
registry.register(oldResident.name, oldInstance, {
|
|
123
|
+
samplingDefaults: oldEntry?.preset.sampling,
|
|
124
|
+
maxOutputTokens: oldEntry?.preset.maxOutputTokens,
|
|
125
|
+
});
|
|
126
|
+
resident = oldResident;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
instance = loaded;
|
|
132
|
+
registry.register(targetEntry.name, instance, {
|
|
133
|
+
samplingDefaults: targetEntry.preset.sampling,
|
|
134
|
+
maxOutputTokens: targetEntry.preset.maxOutputTokens,
|
|
135
|
+
});
|
|
136
|
+
resident = { name: targetEntry.name };
|
|
137
|
+
}
|
|
138
|
+
else if (!resident) {
|
|
139
|
+
resident = { name: targetEntry.name };
|
|
140
|
+
}
|
|
141
|
+
// Re-point any aliases carried across the swap onto the new
|
|
142
|
+
// resident. `registry.register(sameName, differentModel)` drops
|
|
143
|
+
// the old binding's refcount and installs the new one atomically,
|
|
144
|
+
// so any concurrent `registry.get(alias)` either sees the old or
|
|
145
|
+
// new instance — never null.
|
|
146
|
+
for (const aliasName of carriedAliases) {
|
|
147
|
+
if (aliasName === targetEntry.name)
|
|
148
|
+
continue;
|
|
149
|
+
registry.register(aliasName, instance, {
|
|
150
|
+
samplingDefaults: targetEntry.preset.sampling,
|
|
151
|
+
maxOutputTokens: targetEntry.preset.maxOutputTokens,
|
|
152
|
+
});
|
|
153
|
+
aliases.add(aliasName);
|
|
154
|
+
}
|
|
155
|
+
// For unknown names, register an alias on the resident instance so
|
|
156
|
+
// the endpoint's `registry.get(name)` lookup succeeds.
|
|
157
|
+
if (isAlias) {
|
|
158
|
+
registry.register(name, instance, {
|
|
159
|
+
samplingDefaults: targetEntry.preset.sampling,
|
|
160
|
+
maxOutputTokens: targetEntry.preset.maxOutputTokens,
|
|
161
|
+
});
|
|
162
|
+
aliases.add(name);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
currentOp = next.catch(() => undefined);
|
|
166
|
+
await next;
|
|
167
|
+
}
|
|
168
|
+
function listModels() {
|
|
169
|
+
const created = Math.floor(Date.now() / 1000);
|
|
170
|
+
return ordered.map((entry) => ({
|
|
171
|
+
id: entry.name,
|
|
172
|
+
object: 'model',
|
|
173
|
+
created,
|
|
174
|
+
owned_by: 'mlx-node',
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
return { resolveModel, listModels };
|
|
178
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pid-scoped temp roots for the host's paged-config overrides, plus the
|
|
3
|
+
* startup sweep that reclaims the ones a killed host left behind.
|
|
4
|
+
*
|
|
5
|
+
* `PagedConfigOverrideManager` clones a checkpoint directory (config.json
|
|
6
|
+
* rewritten, everything else symlinked) into a temp root and removes that root
|
|
7
|
+
* in `cleanup()`. `cleanup()` runs on a normal shutdown — but the host's
|
|
8
|
+
* headline deployment is an Electron `utilityProcess`, and a `utilityProcess`
|
|
9
|
+
* can be SIGKILLed (app force-quit, OOM killer, `kill -9`). SIGKILL runs no
|
|
10
|
+
* handler, so without a sweep EVERY hard kill leaks a root. The clones are
|
|
11
|
+
* symlink farms rather than copies, so the leak is inodes and directory
|
|
12
|
+
* entries rather than model-sized bytes — but the roots accumulate forever and
|
|
13
|
+
* a partially-written clone can hold a real config.json.
|
|
14
|
+
*
|
|
15
|
+
* The reclaim strategy is "name the owner in the directory name": the manager
|
|
16
|
+
* gets a `tempDirPrefix` of `mlx-inference-host-<pid>-`, `mkdtemp` appends its
|
|
17
|
+
* own random suffix, and {@link sweepOrphanHostTempRoots} parses the pid back
|
|
18
|
+
* out and removes any root whose owner is gone.
|
|
19
|
+
*
|
|
20
|
+
* Known limitation: pid reuse. A long-dead host's root whose pid has since
|
|
21
|
+
* been recycled by an unrelated process is SPARED (never wrongly deleted), so
|
|
22
|
+
* the failure mode is a leaked directory, not data loss.
|
|
23
|
+
*/
|
|
24
|
+
/** Shared stem. A directory is host-owned iff its name starts with this. */
|
|
25
|
+
export declare const HOST_TEMP_DIR_STEM = "mlx-inference-host-";
|
|
26
|
+
/**
|
|
27
|
+
* `tempDirPrefix` to hand `PagedConfigOverrideManager` so the root it creates
|
|
28
|
+
* carries its owner's pid.
|
|
29
|
+
*/
|
|
30
|
+
export declare function hostTempDirPrefix(pid?: number): string;
|
|
31
|
+
/**
|
|
32
|
+
* Does a process with this pid exist?
|
|
33
|
+
*
|
|
34
|
+
* `process.kill(pid, 0)` sends no signal; it only performs the permission +
|
|
35
|
+
* existence check. `ESRCH` is the sole "gone" answer — `EPERM` means the
|
|
36
|
+
* process exists but belongs to another user, which must count as ALIVE so a
|
|
37
|
+
* multi-user box never has one user's sweep delete another user's live root.
|
|
38
|
+
*/
|
|
39
|
+
export declare function isProcessAlive(pid: number): boolean;
|
|
40
|
+
export interface SweepOrphanTempRootsOptions {
|
|
41
|
+
/** Directory to scan. Defaults to the OS temp dir. */
|
|
42
|
+
root?: string;
|
|
43
|
+
/** Our own pid — never swept, however the liveness probe answers. */
|
|
44
|
+
selfPid?: number;
|
|
45
|
+
/** Injectable liveness probe. Defaults to {@link isProcessAlive}. */
|
|
46
|
+
isAlive?: (pid: number) => boolean;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Remove every host temp root whose owning pid is no longer alive.
|
|
50
|
+
*
|
|
51
|
+
* Best effort by contract: a scan or unlink failure (permissions, a root a
|
|
52
|
+
* concurrently-exiting host is removing under us) is swallowed, because a
|
|
53
|
+
* housekeeping step must never be the reason a host refuses to start.
|
|
54
|
+
* Returns the absolute paths actually removed.
|
|
55
|
+
*/
|
|
56
|
+
export declare function sweepOrphanHostTempRoots(opts?: SweepOrphanTempRootsOptions): Promise<string[]>;
|
|
57
|
+
//# sourceMappingURL=temp-root.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"temp-root.d.ts","sourceRoot":"","sources":["../../src/host/temp-root.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAMH,4EAA4E;AAC5E,eAAO,MAAM,kBAAkB,wBAAwB,CAAC;AAKxD;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,GAAE,MAAoB,GAAG,MAAM,CAEnE;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAWnD;AAED,MAAM,WAAW,2BAA2B;IAC1C,sDAAsD;IACtD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,qEAAqE;IACrE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qEAAqE;IACrE,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;CACpC;AAED;;;;;;;GAOG;AACH,wBAAsB,wBAAwB,CAAC,IAAI,GAAE,2BAAgC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CA6BxG"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pid-scoped temp roots for the host's paged-config overrides, plus the
|
|
3
|
+
* startup sweep that reclaims the ones a killed host left behind.
|
|
4
|
+
*
|
|
5
|
+
* `PagedConfigOverrideManager` clones a checkpoint directory (config.json
|
|
6
|
+
* rewritten, everything else symlinked) into a temp root and removes that root
|
|
7
|
+
* in `cleanup()`. `cleanup()` runs on a normal shutdown — but the host's
|
|
8
|
+
* headline deployment is an Electron `utilityProcess`, and a `utilityProcess`
|
|
9
|
+
* can be SIGKILLed (app force-quit, OOM killer, `kill -9`). SIGKILL runs no
|
|
10
|
+
* handler, so without a sweep EVERY hard kill leaks a root. The clones are
|
|
11
|
+
* symlink farms rather than copies, so the leak is inodes and directory
|
|
12
|
+
* entries rather than model-sized bytes — but the roots accumulate forever and
|
|
13
|
+
* a partially-written clone can hold a real config.json.
|
|
14
|
+
*
|
|
15
|
+
* The reclaim strategy is "name the owner in the directory name": the manager
|
|
16
|
+
* gets a `tempDirPrefix` of `mlx-inference-host-<pid>-`, `mkdtemp` appends its
|
|
17
|
+
* own random suffix, and {@link sweepOrphanHostTempRoots} parses the pid back
|
|
18
|
+
* out and removes any root whose owner is gone.
|
|
19
|
+
*
|
|
20
|
+
* Known limitation: pid reuse. A long-dead host's root whose pid has since
|
|
21
|
+
* been recycled by an unrelated process is SPARED (never wrongly deleted), so
|
|
22
|
+
* the failure mode is a leaked directory, not data loss.
|
|
23
|
+
*/
|
|
24
|
+
import { readdir, rm } from 'node:fs/promises';
|
|
25
|
+
import { tmpdir } from 'node:os';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
/** Shared stem. A directory is host-owned iff its name starts with this. */
|
|
28
|
+
export const HOST_TEMP_DIR_STEM = 'mlx-inference-host-';
|
|
29
|
+
/** Matches `mlx-inference-host-<pid>-<mkdtemp suffix>`. */
|
|
30
|
+
const HOST_TEMP_DIR_RE = /^mlx-inference-host-(\d+)-/;
|
|
31
|
+
/**
|
|
32
|
+
* `tempDirPrefix` to hand `PagedConfigOverrideManager` so the root it creates
|
|
33
|
+
* carries its owner's pid.
|
|
34
|
+
*/
|
|
35
|
+
export function hostTempDirPrefix(pid = process.pid) {
|
|
36
|
+
return `${HOST_TEMP_DIR_STEM}${pid}-`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Does a process with this pid exist?
|
|
40
|
+
*
|
|
41
|
+
* `process.kill(pid, 0)` sends no signal; it only performs the permission +
|
|
42
|
+
* existence check. `ESRCH` is the sole "gone" answer — `EPERM` means the
|
|
43
|
+
* process exists but belongs to another user, which must count as ALIVE so a
|
|
44
|
+
* multi-user box never has one user's sweep delete another user's live root.
|
|
45
|
+
*/
|
|
46
|
+
export function isProcessAlive(pid) {
|
|
47
|
+
// pid 0 addresses the caller's whole process group on POSIX and pid < 0 a
|
|
48
|
+
// group by id; neither is ever a real owner, and signalling them would be
|
|
49
|
+
// actively dangerous. Treat as alive so they are never swept.
|
|
50
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
51
|
+
return true;
|
|
52
|
+
try {
|
|
53
|
+
process.kill(pid, 0);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
return err.code !== 'ESRCH';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Remove every host temp root whose owning pid is no longer alive.
|
|
62
|
+
*
|
|
63
|
+
* Best effort by contract: a scan or unlink failure (permissions, a root a
|
|
64
|
+
* concurrently-exiting host is removing under us) is swallowed, because a
|
|
65
|
+
* housekeeping step must never be the reason a host refuses to start.
|
|
66
|
+
* Returns the absolute paths actually removed.
|
|
67
|
+
*/
|
|
68
|
+
export async function sweepOrphanHostTempRoots(opts = {}) {
|
|
69
|
+
const root = opts.root ?? tmpdir();
|
|
70
|
+
const selfPid = opts.selfPid ?? process.pid;
|
|
71
|
+
const isAlive = opts.isAlive ?? isProcessAlive;
|
|
72
|
+
let entries;
|
|
73
|
+
try {
|
|
74
|
+
entries = await readdir(root);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
const removed = [];
|
|
80
|
+
for (const name of entries) {
|
|
81
|
+
const match = HOST_TEMP_DIR_RE.exec(name);
|
|
82
|
+
if (match === null)
|
|
83
|
+
continue;
|
|
84
|
+
const pid = Number.parseInt(match[1], 10);
|
|
85
|
+
if (pid === selfPid)
|
|
86
|
+
continue;
|
|
87
|
+
if (isAlive(pid))
|
|
88
|
+
continue;
|
|
89
|
+
const full = join(root, name);
|
|
90
|
+
try {
|
|
91
|
+
await rm(full, { recursive: true, force: true });
|
|
92
|
+
removed.push(full);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
/* another sweeper won the race, or we lack permission; leave it */
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return removed;
|
|
99
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idle cache-pool sweeper.
|
|
3
|
+
*
|
|
4
|
+
* The MLX Metal allocator holds a process-wide free pool. Without an
|
|
5
|
+
* explicit drain the pool can sit near the wired ceiling (tens of GB on
|
|
6
|
+
* an M3 Max) for the entire server uptime. `@mlx-node/core`'s decode
|
|
7
|
+
* loop already calls `mlx_clear_cache()` every 256 steps, but that
|
|
8
|
+
* cadence only fires while a generation is in progress — idle periods
|
|
9
|
+
* between HTTP requests never get a drain.
|
|
10
|
+
*
|
|
11
|
+
* An earlier iteration put a `ClearCacheOnDrop` RAII guard around every
|
|
12
|
+
* session command so the pool was flushed after every turn. That is
|
|
13
|
+
* wrong on a multi-model server: the free pool is shared across model
|
|
14
|
+
* instances, so flushing after a request on model A discards blocks
|
|
15
|
+
* that model B's next turn is about to reuse.
|
|
16
|
+
*
|
|
17
|
+
* The round-1 replacement was a debounced `touch()`-based sweeper:
|
|
18
|
+
* every request start / end reset a 30s timer that fired `clearCache()`
|
|
19
|
+
* when the timer expired. That design had a correctness bug flagged in
|
|
20
|
+
* round-2 review: a long-running request (streaming decode of 60+
|
|
21
|
+
* seconds) had its 30s timer armed at ARRIVAL, and the timer fired
|
|
22
|
+
* mid-request at t=30s — exactly when the generation thread still
|
|
23
|
+
* needed those blocks. Worse, `clearCache()` routes through
|
|
24
|
+
* `mlx_synchronize()` WITHOUT a stream argument, which only drains the
|
|
25
|
+
* default stream (see `crates/mlx-sys/mlx/mlx/scheduler.cpp`), but
|
|
26
|
+
* decode runs on custom streams — so the drain could race with live
|
|
27
|
+
* command buffers, risking buffer use-after-free.
|
|
28
|
+
*
|
|
29
|
+
* This version fixes both by tracking `inFlight` explicitly. The timer
|
|
30
|
+
* is ONLY armed when the counter returns to zero, and is cancelled the
|
|
31
|
+
* instant any new request arrives — mid-request drains are impossible
|
|
32
|
+
* by construction.
|
|
33
|
+
*
|
|
34
|
+
* # Scope: inference endpoints only
|
|
35
|
+
*
|
|
36
|
+
* The counter is bumped by `/v1/responses` and `/v1/messages` ONLY.
|
|
37
|
+
* Non-inference traffic — `/v1/models`, `/v1/health`, CORS `OPTIONS`
|
|
38
|
+
* preflights, 404s for unknown routes — deliberately does NOT touch
|
|
39
|
+
* the sweeper. Counting those would let a `GET /v1/models` on every
|
|
40
|
+
* client startup, a CORS preflight from a browser, or a cron health
|
|
41
|
+
* probe keep the allocator pinned forever.
|
|
42
|
+
*
|
|
43
|
+
* # Drain is post-request only
|
|
44
|
+
*
|
|
45
|
+
* The only drain path is post-request: `endRequest()` decrements the
|
|
46
|
+
* in-flight counter and, on the 1 -> 0 transition, arms the timer. A
|
|
47
|
+
* cold-idle server that loaded models but never served a request
|
|
48
|
+
* never drains. That is deliberate:
|
|
49
|
+
*
|
|
50
|
+
* - Earlier iterations armed a cold-start drain from either
|
|
51
|
+
* `createServer()` (regressed on slow model loads > `idleClearCacheMs`,
|
|
52
|
+
* firing mid-load) or from `ModelRegistry.register()` (round-7 review
|
|
53
|
+
* surfaced that it still raced sequential multi-model loads — the
|
|
54
|
+
* timer armed by `register(A)` could fire while `await load(B)` was
|
|
55
|
+
* still resolving). Loader-bracketing would require API changes on
|
|
56
|
+
* the user-facing load path, so we remove the cold-start path
|
|
57
|
+
* entirely instead.
|
|
58
|
+
* - Load-time allocator growth is bounded (a few GB of scratch), not
|
|
59
|
+
* the tens-of-GB problem the post-request drain targets. macOS
|
|
60
|
+
* handles truly-idle memory pressure via compression / swap.
|
|
61
|
+
* - Python `mlx-lm` has no cold-start drain either, so this matches
|
|
62
|
+
* that baseline for servers that never receive a request.
|
|
63
|
+
*
|
|
64
|
+
* # Hot-load bracketing: `withSuspendedDrains`
|
|
65
|
+
*
|
|
66
|
+
* Hot-load flows — a `Model::load()` invoked AFTER the server has
|
|
67
|
+
* already served at least one request — race the post-request drain
|
|
68
|
+
* timer: the t+delayMs timer armed by the previous `endRequest()` can
|
|
69
|
+
* fire MID-LOAD while weight materialization is still allocating
|
|
70
|
+
* through the Metal free pool. The canonical fix is to bracket the
|
|
71
|
+
* load with `withSuspendedDrains(fn)`:
|
|
72
|
+
*
|
|
73
|
+
* ```ts
|
|
74
|
+
* await server.withSuspendedDrains(async () => {
|
|
75
|
+
* const model = await Qwen35MoeModel.load(modelPath);
|
|
76
|
+
* server.registry.register('qwen', model);
|
|
77
|
+
* });
|
|
78
|
+
* ```
|
|
79
|
+
*
|
|
80
|
+
* `withSuspendedDrains` handles try/finally bracketing itself, so a
|
|
81
|
+
* thrown load never leaks the internal suspend counter — a footgun the
|
|
82
|
+
* earlier raw `suspendDrains()` / `resumeDrains()` pair exposed
|
|
83
|
+
* (round-9 MEDIUM). The low-level `suspendDrains()` entry point
|
|
84
|
+
* remains available for callers that genuinely need manual control;
|
|
85
|
+
* its returned disposer is token-scoped and idempotent.
|
|
86
|
+
*
|
|
87
|
+
* `withSuspendedDrains` is also thenable-safe: non-Promise thenables
|
|
88
|
+
* (plain objects or even callables that expose a `.then` method) are
|
|
89
|
+
* normalized via `Promise.resolve()` so callers always get a real
|
|
90
|
+
* Promise<T> back and the suspend-release fires on settle. A throwing
|
|
91
|
+
* `.then` getter is treated as non-thenable (the getter access is
|
|
92
|
+
* guarded); the value is returned directly and the suspend releases
|
|
93
|
+
* synchronously — we deliberately do NOT propagate a getter throw, on
|
|
94
|
+
* the theory that leaking the suspend is a far worse failure mode than
|
|
95
|
+
* losing a pathological value (round-10 MEDIUM).
|
|
96
|
+
*
|
|
97
|
+
* # Tuning
|
|
98
|
+
*
|
|
99
|
+
* - The default (30_000 ms) balances "give models a chance to reuse
|
|
100
|
+
* hot blocks across back-to-back requests" against "don't hold
|
|
101
|
+
* tens of GB hostage while the process is truly idle".
|
|
102
|
+
* - `idleClearCacheMs: 0` disables the sweeper entirely — useful for
|
|
103
|
+
* benchmarks or single-model workloads where the only memory
|
|
104
|
+
* pressure is the decode-loop cadence already in place.
|
|
105
|
+
* `withSuspendedDrains(fn)` on the disabled sweeper is a pass-through
|
|
106
|
+
* so call sites can unconditionally bracket.
|
|
107
|
+
* - `MLX_IDLE_CLEAR_CACHE_MS` env var overrides the server's
|
|
108
|
+
* constructor value; explicit constructor value wins over env.
|
|
109
|
+
*
|
|
110
|
+
* Drain cost is a single `mlx_synchronize` + `mlx_clear_cache` —
|
|
111
|
+
* constant time relative to generation length.
|
|
112
|
+
*/
|
|
113
|
+
/** Default idle window before draining the allocator's free pool (ms). */
|
|
114
|
+
export declare const DEFAULT_IDLE_CLEAR_CACHE_MS = 30000;
|
|
115
|
+
/**
|
|
116
|
+
* Parse `MLX_IDLE_CLEAR_CACHE_MS`. Same semantics as the other env
|
|
117
|
+
* knobs in `server.ts`: finite non-negative integer or fall through
|
|
118
|
+
* to the caller's default. A value of `0` explicitly disables the
|
|
119
|
+
* sweeper; negative / non-integer / unparseable values are ignored.
|
|
120
|
+
*/
|
|
121
|
+
export declare function parseIdleClearCacheEnv(): number | undefined;
|
|
122
|
+
/**
|
|
123
|
+
* In-flight counter-based idle-drain scheduler.
|
|
124
|
+
*
|
|
125
|
+
* - `beginRequest()` is called ONCE per inference request BEFORE the
|
|
126
|
+
* native model is dispatched. It increments the in-flight counter
|
|
127
|
+
* and cancels any pending drain timer — eliminating the mid-request
|
|
128
|
+
* drain race the debounced-`touch()` design had. Only the inference
|
|
129
|
+
* endpoints (`/v1/responses` + `/v1/messages`) should call this;
|
|
130
|
+
* `/v1/models`, `/v1/health`, and CORS preflights MUST NOT, or they
|
|
131
|
+
* would keep the allocator pinned forever on purely observational
|
|
132
|
+
* traffic.
|
|
133
|
+
* - `endRequest()` is called ONCE per request in a `finally` block
|
|
134
|
+
* after the model stream has fully ended (covering success, error,
|
|
135
|
+
* and client-abort paths). It decrements the counter; when the
|
|
136
|
+
* counter reaches zero it arms a `delayMs` timer that calls
|
|
137
|
+
* `onDrain()` on expiry. This is the ONLY path that ever schedules
|
|
138
|
+
* a drain — see the module-level "Drain is post-request only"
|
|
139
|
+
* note for why cold-start arming was removed.
|
|
140
|
+
* - `close()` cancels any pending drain — used during graceful
|
|
141
|
+
* shutdown so the timer does not keep Node alive after
|
|
142
|
+
* `server.close()`. It intentionally does NOT reset the counter:
|
|
143
|
+
* draining partway through in-flight requests is the exact failure
|
|
144
|
+
* mode we're avoiding.
|
|
145
|
+
*
|
|
146
|
+
* Thread-safety is provided by Node's single-threaded event loop —
|
|
147
|
+
* `beginRequest` / `endRequest` are only ever invoked from the HTTP
|
|
148
|
+
* handler (or tests). Every `beginRequest()` MUST be paired with
|
|
149
|
+
* exactly one `endRequest()` on every exit path — a missed
|
|
150
|
+
* `endRequest()` would leave the counter pinned above zero and the
|
|
151
|
+
* drain would never fire.
|
|
152
|
+
*/
|
|
153
|
+
export interface IdleSweeper {
|
|
154
|
+
/** Mark a request as arrived. Cancels any pending drain. */
|
|
155
|
+
beginRequest(): void;
|
|
156
|
+
/**
|
|
157
|
+
* Mark a request as completed. When the in-flight counter reaches
|
|
158
|
+
* zero, arms a drain timer for `delayMs`.
|
|
159
|
+
*/
|
|
160
|
+
endRequest(): void;
|
|
161
|
+
/** Cancel the pending drain. Idempotent. Does NOT reset the counter. */
|
|
162
|
+
close(): void;
|
|
163
|
+
/**
|
|
164
|
+
* Run `fn` with drains suspended. Handles try/finally bracketing so
|
|
165
|
+
* a thrown load never leaks the internal suspend counter. This is
|
|
166
|
+
* the canonical entry point for hot-load flows — a `Model::load()`
|
|
167
|
+
* invoked AFTER the server has already served at least one request.
|
|
168
|
+
* In that scenario the post-request drain timer armed by
|
|
169
|
+
* `endRequest()` (t+delayMs) can otherwise fire MID-LOAD while
|
|
170
|
+
* weight materialization is still allocating through the Metal free
|
|
171
|
+
* pool, racing the allocator state.
|
|
172
|
+
*
|
|
173
|
+
* Accepts both sync and async functions; returns `fn`'s own return
|
|
174
|
+
* value (or resolved promise). On exit (normal or thrown), the
|
|
175
|
+
* suspend token is disposed exactly once — the drain timer is
|
|
176
|
+
* re-armed if `inFlight === 0` and no other suspend is active.
|
|
177
|
+
*
|
|
178
|
+
* Thenable-safe: non-Promise thenables (including function-typed
|
|
179
|
+
* values that expose a `.then` method) are normalized via
|
|
180
|
+
* `Promise.resolve()` so callers always receive a real `Promise<T>`
|
|
181
|
+
* on the async branch; a throwing `.then` getter is guarded and the
|
|
182
|
+
* value is treated as non-thenable rather than leaking the suspend.
|
|
183
|
+
*
|
|
184
|
+
* The common `serve.ts` pattern — load all models before
|
|
185
|
+
* `createServer(...)`, i.e. before any request is served — has no
|
|
186
|
+
* armed timer in the first place and therefore does NOT need to
|
|
187
|
+
* bracket.
|
|
188
|
+
*
|
|
189
|
+
* Pass-through on the disabled sweeper (`delayMs <= 0`): `fn` is
|
|
190
|
+
* invoked directly and its return value is returned unchanged, so
|
|
191
|
+
* call sites can unconditionally bracket without branching.
|
|
192
|
+
*
|
|
193
|
+
* @example
|
|
194
|
+
* ```ts
|
|
195
|
+
* await server.withSuspendedDrains(async () => {
|
|
196
|
+
* const model = await Qwen35MoeModel.load(modelPath);
|
|
197
|
+
* server.registry.register('qwen', model);
|
|
198
|
+
* });
|
|
199
|
+
* ```
|
|
200
|
+
*/
|
|
201
|
+
withSuspendedDrains<T>(fn: () => Promise<T>): Promise<T>;
|
|
202
|
+
withSuspendedDrains<T>(fn: () => T): T;
|
|
203
|
+
/**
|
|
204
|
+
* Low-level: suspend drains and return an idempotent disposer.
|
|
205
|
+
* Prefer {@link withSuspendedDrains} unless you need manual
|
|
206
|
+
* control over when the suspend is released (for example across
|
|
207
|
+
* async boundaries the caller wants to manage explicitly).
|
|
208
|
+
*
|
|
209
|
+
* Semantics: cancels any pending drain and increments an internal
|
|
210
|
+
* "load" counter. Returns a disposer — calling the disposer
|
|
211
|
+
* decrements the counter. When the counter reaches zero AND
|
|
212
|
+
* `inFlight === 0`, a fresh drain timer is armed so a subsequent
|
|
213
|
+
* idle window still drains.
|
|
214
|
+
*
|
|
215
|
+
* Safe to nest: N suspends require N dispose calls. The returned
|
|
216
|
+
* disposer is token-scoped and idempotent — calling it more than
|
|
217
|
+
* once is a no-op, so it cannot over-decrement and mysteriously
|
|
218
|
+
* shift the idle window.
|
|
219
|
+
*
|
|
220
|
+
* No-op on the disabled sweeper (`delayMs <= 0`): returns a no-op
|
|
221
|
+
* disposer so call sites can unconditionally bracket without
|
|
222
|
+
* branching on whether the sweeper is enabled.
|
|
223
|
+
*/
|
|
224
|
+
suspendDrains(): () => void;
|
|
225
|
+
/** Observability hook — `true` while a drain is scheduled. */
|
|
226
|
+
readonly isPending: boolean;
|
|
227
|
+
/** Observability hook — current in-flight request count. */
|
|
228
|
+
readonly inFlight: number;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Create an idle sweeper. Pass `0` or a non-positive value to opt out —
|
|
232
|
+
* the returned object becomes a no-op that still satisfies the
|
|
233
|
+
* interface. Callers can therefore unconditionally wire
|
|
234
|
+
* `beginRequest()` / `endRequest()` without branching on whether the
|
|
235
|
+
* sweeper is enabled.
|
|
236
|
+
*
|
|
237
|
+
* When `onDrain` is omitted, the sweeper resolves
|
|
238
|
+
* `__internal__.clearCache` on `@mlx-node/core` at creation time and
|
|
239
|
+
* caches the result in the returned closure. A missing namespace /
|
|
240
|
+
* function triggers a one-time `console.warn` and a no-op fallback —
|
|
241
|
+
* see `resolveClearCache()`. The `delayMs <= 0` path skips the
|
|
242
|
+
* resolution entirely so the opt-out remains purely passive.
|
|
243
|
+
*/
|
|
244
|
+
export declare function createIdleSweeper(delayMs: number, onDrain?: () => void): IdleSweeper;
|
|
245
|
+
//# sourceMappingURL=idle-sweeper.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"idle-sweeper.d.ts","sourceRoot":"","sources":["../src/idle-sweeper.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+GG;AAsBH,0EAA0E;AAC1E,eAAO,MAAM,2BAA2B,QAAS,CAAC;AA4BlD;;;;;GAKG;AACH,wBAAgB,sBAAsB,IAAI,MAAM,GAAG,SAAS,CAO3D;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,MAAM,WAAW,WAAW;IAC1B,4DAA4D;IAC5D,YAAY,IAAI,IAAI,CAAC;IACrB;;;OAGG;IACH,UAAU,IAAI,IAAI,CAAC;IACnB,wEAAwE;IACxE,KAAK,IAAI,IAAI,CAAC;IACd;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAqCG;IACH,mBAAmB,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IACzD,mBAAmB,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC;IACvC;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,aAAa,IAAI,MAAM,IAAI,CAAC;IAC5B,8DAA8D;IAC9D,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,4DAA4D;IAC5D,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AA4CD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,IAAI,GAAG,WAAW,CAoNpF"}
|