arcane-os 0.77.0 → 0.79.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,288 @@
1
+ import {readFile} from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {inspect} from 'node:util';
4
+ import Is from 'strong-type';
5
+ import {ArcaneError, ERROR_CODES} from '../errors.mjs';
6
+ import {runProcess} from '../process.mjs';
7
+
8
+ const is = new Is(false);
9
+
10
+ /**
11
+ * One lazy physical-device inventory per host lifetime. Concurrent callers share
12
+ * discovery; refresh explicitly replaces the cached observation. Returned records
13
+ * are ordinary independent snapshots, not engine-placement or affinity claims.
14
+ * Cancellation rejects with AbortError/ARCANE_CANCELLED. The last departing
15
+ * caller stops discovery; dispose waits for the owned process to finish stopping.
16
+ */
17
+ export function createExecutionDeviceCatalog({signal} = {}) {
18
+ requireSignal(signal);
19
+ const lifetime = new AbortController();
20
+ const waiting = new Set();
21
+ let cached = null;
22
+ let active = null;
23
+ let closing = null;
24
+
25
+ function requireActive(requestSignal) {
26
+ requireSignal(requestSignal);
27
+ if (requestSignal?.aborted) throw cancellation(requestSignal.reason);
28
+ if (lifetime.signal.aborted) throw cancellation(lifetime.signal.reason);
29
+ }
30
+
31
+ function startDiscovery() {
32
+ const operation = {
33
+ controller: new AbortController(),
34
+ callers: 0,
35
+ finished: false,
36
+ task: null
37
+ };
38
+ active = operation;
39
+ operation.task = discoverDevices(operation.controller.signal).then(
40
+ function discoveryCompleted(value) {
41
+ operation.finished = true;
42
+ if (active === operation) active = null;
43
+ if (operation.controller.signal.aborted) {
44
+ return {error: cancellation(operation.controller.signal.reason)};
45
+ }
46
+ cached = value;
47
+ return {value};
48
+ },
49
+ function discoveryFailed(error) {
50
+ operation.finished = true;
51
+ if (active === operation) active = null;
52
+ return {error};
53
+ }
54
+ );
55
+ return operation;
56
+ }
57
+
58
+ function waitForDiscovery(operation, requestSignal, ownsQuery = true) {
59
+ return new Promise(
60
+ function waitForOwnedDiscovery(resolve, reject) {
61
+ let settled = false;
62
+ if (ownsQuery) operation.callers += 1;
63
+
64
+ function releaseCaller() {
65
+ requestSignal?.removeEventListener('abort', cancelCaller);
66
+ waiting.delete(cancelCaller);
67
+ if (ownsQuery) operation.callers -= 1;
68
+ }
69
+
70
+ function cancelCaller() {
71
+ if (settled) return;
72
+ settled = true;
73
+ const reason = requestSignal?.aborted
74
+ ? requestSignal.reason
75
+ : lifetime.signal.reason;
76
+ const error = cancellation(reason);
77
+ releaseCaller();
78
+ if (ownsQuery && operation.callers === 0 && !operation.finished) {
79
+ operation.controller.abort(error);
80
+ }
81
+ reject(error);
82
+ }
83
+
84
+ waiting.add(cancelCaller);
85
+ requestSignal?.addEventListener(
86
+ 'abort', cancelCaller,
87
+ {once: true}
88
+ );
89
+ operation.task.then(
90
+ function deliverDiscovery(outcome) {
91
+ if (settled) return;
92
+ if (requestSignal?.aborted || lifetime.signal.aborted) {
93
+ cancelCaller();
94
+ return;
95
+ }
96
+ settled = true;
97
+ releaseCaller();
98
+ // A replacement query waits only for the cancelled process
99
+ // to retire; its old cancellation is not the new request's.
100
+ if (ownsQuery && outcome.error) reject(outcome.error);
101
+ else resolve(outcome.value);
102
+ }
103
+ );
104
+ if (requestSignal?.aborted || lifetime.signal.aborted) cancelCaller();
105
+ }
106
+ );
107
+ }
108
+
109
+ async function devices({refresh = false, signal: requestSignal} = {}) {
110
+ requireActive(requestSignal);
111
+ if (refresh) cached = null;
112
+ while (active?.controller.signal.aborted) {
113
+ await waitForDiscovery(active, requestSignal, false);
114
+ requireActive(requestSignal);
115
+ }
116
+ if (cached && !refresh) return structuredClone(cached);
117
+ const operation = active ?? startDiscovery();
118
+ const inventory = await waitForDiscovery(operation, requestSignal);
119
+ requireActive(requestSignal);
120
+ return structuredClone(inventory);
121
+ }
122
+
123
+ async function resolveTarget({executionTarget = null, refresh = false, signal: requestSignal} = {}) {
124
+ requireActive(requestSignal);
125
+ if (executionTarget === null) {
126
+ return {
127
+ requestedTarget: null,
128
+ resolvedDevice: null,
129
+ resolution: 'automatic',
130
+ reason: 'engine-default-required'
131
+ };
132
+ }
133
+ if (!is.string(executionTarget?.deviceId) || executionTarget.deviceId === '') {
134
+ throw new TypeError('An execution target requires a deviceId or an explicit null automatic selection.');
135
+ }
136
+ const requestedTarget = {deviceId: executionTarget.deviceId};
137
+ const inventory = await devices(
138
+ {refresh, signal: requestSignal}
139
+ );
140
+ requireActive(requestSignal);
141
+ const device = inventory.devices.find(
142
+ function matchesRequestedDevice(candidate) {
143
+ return candidate.present && candidate.deviceId === requestedTarget.deviceId;
144
+ }
145
+ );
146
+ if (device) {
147
+ return {requestedTarget, resolvedDevice: device, resolution: 'matched', reason: null};
148
+ }
149
+ const cpuRequested = requestedTarget.deviceId === 'cpu';
150
+ const unresolvedIdentity = !cpuRequested && inventory.devices.some(
151
+ function hasUnresolvedIdentity(candidate) {
152
+ return candidate.present && candidate.deviceId === null;
153
+ }
154
+ );
155
+ const incompleteInventory = inventory.state === 'unavailable' || inventory.issues.some(
156
+ function enumerationUnavailable(issue) {
157
+ const relevant = cpuRequested
158
+ ? issue.source === 'cpu'
159
+ : issue.source === 'gpu' || issue.source === 'npu';
160
+ return relevant && (issue.code === 'enumeration-unavailable' || issue.code === 'enumeration-failed');
161
+ }
162
+ );
163
+ const reason = incompleteInventory
164
+ ? 'inventory-unavailable'
165
+ : unresolvedIdentity ? 'identity-unresolved' : 'device-not-present';
166
+ return {
167
+ requestedTarget,
168
+ resolvedDevice: null,
169
+ resolution: reason === 'device-not-present' ? 'unavailable' : 'unsupported',
170
+ reason
171
+ };
172
+ }
173
+
174
+ function dispose() {
175
+ if (closing) return closing;
176
+ const operation = active;
177
+ signal?.removeEventListener('abort', cancelLifetime);
178
+ cached = null;
179
+ lifetime.abort(signal?.aborted ? signal.reason : cancellation('Execution-device catalog disposed.'));
180
+ for (const cancelCaller of waiting) cancelCaller();
181
+ operation?.controller.abort(lifetime.signal.reason);
182
+ closing = operation
183
+ ? operation.task.then(
184
+ function discoveryDisposed(outcome) {
185
+ if (outcome.error && outcome.error.code !== ERROR_CODES.cancelled) throw outcome.error;
186
+ }
187
+ )
188
+ : Promise.resolve();
189
+ return closing;
190
+ }
191
+
192
+ function cancelLifetime() {
193
+ // The retained closing promise owns the process drain even when a parent
194
+ // aborts without immediately awaiting dispose(). Discovery settles it.
195
+ dispose();
196
+ }
197
+
198
+ signal?.addEventListener(
199
+ 'abort', cancelLifetime,
200
+ {once: true}
201
+ );
202
+ if (signal?.aborted) cancelLifetime();
203
+ return {devices, resolveTarget, dispose};
204
+ }
205
+
206
+ async function discoverDevices(signal) {
207
+ if (signal.aborted) throw cancellation(signal.reason);
208
+ if (process.platform !== 'win32') {
209
+ return unavailableInventory(
210
+ 'platform-unavailable',
211
+ `Physical execution-device inventory is unavailable on ${process.platform}.`
212
+ );
213
+ }
214
+ let result;
215
+ try {
216
+ const script = await readFile(
217
+ new URL('./execution-devices-windows.ps1', import.meta.url),
218
+ {encoding: 'utf8', signal}
219
+ );
220
+ if (signal.aborted) throw cancellation(signal.reason);
221
+ const command = process.env.SystemRoot
222
+ ? path.join(process.env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
223
+ : 'powershell.exe';
224
+ async function* discoveryInput() {
225
+ // The process owner's iterable input path observes stdin failures
226
+ // and owns its drain if PowerShell exits before accepting the script.
227
+ yield `& {\n${script}\n}\n\n`;
228
+ }
229
+ result = await runProcess(
230
+ command,
231
+ ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '-'],
232
+ {
233
+ signal,
234
+ input: discoveryInput(),
235
+ captureOutput: true,
236
+ emitOutputEvents: false
237
+ }
238
+ );
239
+ if (signal.aborted) throw cancellation(signal.reason);
240
+ const inventory = JSON.parse(result.stdout);
241
+ if (!inventory || !is.array(inventory.devices) || !is.array(inventory.issues)) {
242
+ throw new TypeError('Windows execution-device discovery returned no inventory record.');
243
+ }
244
+ if (result.stderr !== '') {
245
+ inventory.issues.push(
246
+ {source: 'identity', code: 'discovery-diagnostics', message: result.stderr, deviceId: null}
247
+ );
248
+ if (inventory.state === 'ready') inventory.state = 'partial';
249
+ }
250
+ return inventory;
251
+ } catch (error) {
252
+ if (signal.aborted) throw cancellation(error);
253
+ const details = inspect(
254
+ error,
255
+ {depth: null, maxArrayLength: null, maxStringLength: null, compact: false}
256
+ );
257
+ const output = result ? `\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}` : '';
258
+ return unavailableInventory('discovery-failed', `${details}${output}`);
259
+ }
260
+ }
261
+
262
+ function unavailableInventory(code, message) {
263
+ return {
264
+ platform: process.platform,
265
+ state: 'unavailable',
266
+ devices: [],
267
+ issues: [
268
+ {source: 'identity', code, message, deviceId: null}
269
+ ]
270
+ };
271
+ }
272
+
273
+ function cancellation(reason) {
274
+ const error = new ArcaneError(
275
+ ERROR_CODES.cancelled,
276
+ 'Execution-device discovery was cancelled.',
277
+ {cause: reason, exitCode: 130}
278
+ );
279
+ error.name = 'AbortError';
280
+ return error;
281
+ }
282
+
283
+ function requireSignal(signal) {
284
+ if (signal === undefined || signal === null) return;
285
+ if (!is.boolean(signal.aborted) || !is.function(signal.addEventListener) || !is.function(signal.removeEventListener)) {
286
+ throw new TypeError('Execution-device discovery requires an AbortSignal when signal is supplied.');
287
+ }
288
+ }
@@ -20,13 +20,15 @@ async function createSession(request) {
20
20
  const selectProviders = supplied === undefined
21
21
  || (supplied !== null && is.object(supplied) && supplied.executionProviders === undefined);
22
22
  const options = selectProviders ? {...supplied, executionProviders: ['cpu']} : supplied;
23
- if (request.executionPreference !== 'gpu' || !selectProviders) {
24
- session = await ort.InferenceSession.create(request.model, options);
25
- return undefined;
26
- }
27
-
28
23
  const execution = {
29
- preference: 'gpu',
24
+ preference: request.executionPreference ?? 'cpu',
25
+ requestedTarget: request.executionTarget ?? null,
26
+ resolvedDevice: null,
27
+ resolution: 'automatic',
28
+ reason: selectProviders ? 'engine-default-required' : 'caller-session-options',
29
+ ...request.targetResolution,
30
+ configuredTarget: null,
31
+ observedTarget: null,
30
32
  supportedBackends: [],
31
33
  selectedProviders: [],
32
34
  attempts: [],
@@ -34,19 +36,153 @@ async function createSession(request) {
34
36
  discoveryError: null
35
37
  };
36
38
  const failures = [];
39
+
40
+ function targetFailure(code, reason, message) {
41
+ execution.reason = reason;
42
+ const error = new Error(message);
43
+ error.code = code;
44
+ error.execution = execution;
45
+ return error;
46
+ }
47
+
48
+ function providerName(provider) {
49
+ return is.string(provider) ? provider : provider?.name;
50
+ }
51
+
52
+ async function configure(selectedOptions, source, finalAttempt = true) {
53
+ const executionProviders = selectedOptions?.executionProviders ?? null;
54
+ const names = is.array(executionProviders) ? executionProviders.map(providerName) : [];
55
+ const configuredTarget = {source, executionProviders};
56
+ const attempt = {executionProviders: names, configuredTarget, status: 'loading', error: null};
57
+ execution.attempts.push(attempt);
58
+ try {
59
+ session = await ort.InferenceSession.create(request.model, selectedOptions);
60
+ attempt.status = 'configured';
61
+ execution.selectedProviders = names;
62
+ execution.configuredTarget = configuredTarget;
63
+ // Session creation accepts configuration. Node exposes no physical
64
+ // placement observation; GPU and CPU graph partitioning may coexist.
65
+ return execution;
66
+ } catch (error) {
67
+ attempt.status = 'failed';
68
+ const record = describeError(error);
69
+ attempt.error = record;
70
+ if (finalAttempt) {
71
+ // Preserve primitive and non-extensible provider failures while
72
+ // adding SDK diagnostics without mutating the provider's value.
73
+ throw Object.assign(new Error(record.message, {cause: record.cause}), record, {execution});
74
+ }
75
+ throw error;
76
+ }
77
+ }
78
+
79
+ function hasBackend(name) {
80
+ return execution.supportedBackends.some(
81
+ function supportedBackend(backend) { return backend.name === name; }
82
+ );
83
+ }
84
+
85
+ function physicalOptions(device) {
86
+ let provider;
87
+ if (device.kind === 'cpu' && device.deviceId === 'cpu') {
88
+ provider = 'cpu';
89
+ } else if (device.kind === 'gpu' && hasBackend('dml')
90
+ && is.integer(device.addresses?.dxgiAdapterIndex) && device.addresses.dxgiAdapterIndex >= 0) {
91
+ provider = {name: 'dml', deviceId: device.addresses.dxgiAdapterIndex};
92
+ } else {
93
+ throw targetFailure(
94
+ 'LOCAL_AI_EXECUTION_TARGET_UNSUPPORTED', 'provider-device-mapping-unavailable',
95
+ 'The installed ONNX binding cannot map this physical device to a supported execution provider.'
96
+ );
97
+ }
98
+ if (!selectProviders) {
99
+ const providers = supplied?.executionProviders;
100
+ if (!is.array(providers) || providers.length === 0) {
101
+ throw targetFailure(
102
+ 'LOCAL_AI_EXECUTION_TARGET_CONFLICT', 'session-options-do-not-select-target',
103
+ 'The explicit session options do not select the requested physical execution target.'
104
+ );
105
+ }
106
+ const expectedName = providerName(provider);
107
+ const primary = providers[0];
108
+ const matches = providerName(primary) === expectedName
109
+ && (expectedName === 'cpu' || (primary?.deviceId ?? 0) === provider.deviceId)
110
+ && providers.every(
111
+ function compatibleProvider(value, index) { return index === 0 || providerName(value) === 'cpu'; }
112
+ );
113
+ if (!matches) {
114
+ throw targetFailure(
115
+ 'LOCAL_AI_EXECUTION_TARGET_CONFLICT', 'session-options-target-conflict',
116
+ 'The explicit execution providers conflict with the physical target or have no established mapping to it.'
117
+ );
118
+ }
119
+ }
120
+ const selectedOptions = selectProviders ? {
121
+ ...supplied,
122
+ executionProviders: provider === 'cpu' ? ['cpu'] : [provider, 'cpu']
123
+ } : {...supplied};
124
+ if (providerName(provider) === 'dml') {
125
+ if (selectedOptions.enableMemPattern === undefined) selectedOptions.enableMemPattern = false;
126
+ if (selectedOptions.executionMode === undefined) selectedOptions.executionMode = 'sequential';
127
+ }
128
+ return selectedOptions;
129
+ }
130
+
131
+ const explicitDevice = request.executionTarget !== undefined && request.executionTarget !== null;
132
+ if (explicitDevice && (execution.resolution !== 'matched' || !execution.resolvedDevice)) {
133
+ throw targetFailure(
134
+ execution.resolution === 'unsupported' ? 'LOCAL_AI_EXECUTION_TARGET_UNSUPPORTED' : 'LOCAL_AI_EXECUTION_TARGET_UNAVAILABLE',
135
+ execution.reason ?? 'physical-device-unavailable',
136
+ 'The requested physical execution device could not be resolved.'
137
+ );
138
+ }
139
+ if (request.executionTarget === null && !selectProviders
140
+ && is.array(supplied?.executionProviders) && supplied.executionProviders.some(
141
+ function fixedDevice(provider) { return !is.string(provider) && provider?.deviceId !== undefined; }
142
+ )) {
143
+ throw targetFailure(
144
+ 'LOCAL_AI_EXECUTION_TARGET_CONFLICT', 'automatic-target-fixed-session-device',
145
+ 'An automatic execution target conflicts with an explicit provider deviceId in session options.'
146
+ );
147
+ }
148
+ if (explicitDevice && execution.resolvedDevice.kind === 'cpu') {
149
+ return configure(physicalOptions(execution.resolvedDevice), 'execution-target');
150
+ }
151
+ if (!explicitDevice && (request.executionPreference !== 'gpu' || !selectProviders)) {
152
+ return configure(options, selectProviders ? 'cpu-default' : 'session-options');
153
+ }
37
154
  try {
38
155
  execution.supportedBackends = ort.listSupportedBackends();
39
156
  } catch (error) {
40
157
  execution.discoveryError = describeError(error);
41
158
  failures.push(error);
42
159
  }
160
+ if (explicitDevice) {
161
+ return configure(physicalOptions(execution.resolvedDevice), 'execution-target');
162
+ }
163
+ if (request.executionTarget === null && hasBackend('dml')) {
164
+ const candidates = (request.deviceInventory?.devices ?? []).filter(
165
+ function supportedPhysicalGPU(device) {
166
+ return device.kind === 'gpu' && device.present === true && device.isHardware === true
167
+ && is.string(device.deviceId) && is.finite(device.dedicatedMemoryMiB)
168
+ && device.dedicatedMemoryMiB > 0 && is.integer(device.addresses?.dxgiAdapterIndex)
169
+ && device.addresses.dxgiAdapterIndex >= 0;
170
+ }
171
+ );
172
+ candidates.sort(
173
+ function largestDedicatedMemory(first, second) { return second.dedicatedMemoryMiB - first.dedicatedMemoryMiB; }
174
+ );
175
+ if (candidates.length) {
176
+ execution.resolvedDevice = candidates[0];
177
+ execution.reason = 'largest-supported-dedicated-memory-gpu';
178
+ return configure(physicalOptions(execution.resolvedDevice), 'automatic-physical-target');
179
+ }
180
+ }
43
181
  // The inventory advertises compiled support. `bundled` describes packaging,
44
182
  // not whether an installed provider library or physical device can load.
45
183
  const candidates = ['cuda', 'tensorrt', 'dml', 'coreml', 'webgpu'].filter(
46
184
  function advertisedProvider(name) {
47
- return execution.supportedBackends.some(
48
- function supportedBackend(backend) { return backend.name === name; }
49
- );
185
+ return hasBackend(name);
50
186
  }
51
187
  );
52
188
  candidates.push('cpu');
@@ -59,19 +195,13 @@ async function createSession(request) {
59
195
  if (selectedOptions.enableMemPattern === undefined) selectedOptions.enableMemPattern = false;
60
196
  if (selectedOptions.executionMode === undefined) selectedOptions.executionMode = 'sequential';
61
197
  }
62
- const attempt = {executionProviders, status: 'loading', error: null};
63
- execution.attempts.push(attempt);
64
198
  execution.fallback = provider === 'cpu';
65
199
  try {
66
- session = await ort.InferenceSession.create(request.model, selectedOptions);
67
- attempt.status = 'configured';
68
- execution.selectedProviders = executionProviders;
69
- // Successful creation proves this session configuration was accepted.
70
- // ORT can assign graph nodes to CPU; it does not prove GPU execution.
71
- return execution;
200
+ // Keep intermediate native errors untouched: a provider may reuse
201
+ // one Error object for multiple attempts. The final failure owns
202
+ // the execution record after every attempt has been described.
203
+ return await configure(selectedOptions, 'gpu-preference', false);
72
204
  } catch (error) {
73
- attempt.status = 'failed';
74
- attempt.error = describeError(error);
75
205
  failures.push(error);
76
206
  }
77
207
  }