companion-runtime-manager-unit.test.mjs
file-level
1
files
1
commits
0
hotspots
0
π§ dead
0
π₯ blast risk
| 1 | /** |
| 2 | * Tier 1 β UNIT: lib/companion-runtime-manager.mjs |
| 3 | * |
| 4 | * Smallest behavioural contracts for each exported function in total isolation β |
| 5 | * no network, no env, no child_process, no filesystem. Each section exercises one |
| 6 | * exported function (or one logical control within it) so a failure pinpoints exactly |
| 7 | * which invariant broke. |
| 8 | * |
| 9 | * Reference: docs/COMPANION-APP-PHASE-4-RUNTIME-MANAGER.md (module contract), |
| 10 | * docs/COMPANION-APP-DESIGN-AND-AUTHORIZATION-GATE.md Β§4 (loopback controls), |
| 11 | * docs/COMPANION-APP-PHASE-1-ADAPTER-SEAM.md Β§1.1 (companionAvailable seam). |
| 12 | */ |
| 13 | |
| 14 | import { describe, it } from 'node:test'; |
| 15 | import assert from 'node:assert/strict'; |
| 16 | import crypto from 'node:crypto'; |
| 17 | |
| 18 | import { |
| 19 | RUNTIME_MANAGER_REASONS, |
| 20 | ALLOWED_SOURCE_SCHEMES, |
| 21 | SHA256_HEX_LENGTH, |
| 22 | validateSourceUrl, |
| 23 | validateIntegritySpec, |
| 24 | createIntegrityAccumulator, |
| 25 | verifyModelBytes, |
| 26 | LIFECYCLE_STATES, |
| 27 | LIFECYCLE_EVENTS, |
| 28 | createLifecycleState, |
| 29 | transitionLifecycle, |
| 30 | canServeInference, |
| 31 | createAdmissionState, |
| 32 | evaluateAdmission, |
| 33 | recordInFlight, |
| 34 | recordCompletion, |
| 35 | recordQueued, |
| 36 | recordDequeued, |
| 37 | createResourceLimits, |
| 38 | evaluateResourceLimits, |
| 39 | evaluateRuntimeRequest, |
| 40 | } from '../lib/companion-runtime-manager.mjs'; |
| 41 | |
| 42 | // ββ Shared helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 43 | |
| 44 | function makeDigest(data) { |
| 45 | return crypto.createHash('sha256').update(data).digest('hex'); |
| 46 | } |
| 47 | |
| 48 | const ALLOWED_URLS = ['https://models.example.com/']; |
| 49 | const VALID_URL = 'https://models.example.com/model.bin'; |
| 50 | const VALID_DATA = Buffer.from('hello world model data'); |
| 51 | const VALID_DIGEST = makeDigest(VALID_DATA); |
| 52 | const VALID_SIZE = VALID_DATA.length; |
| 53 | |
| 54 | const READY_STATE = { state: LIFECYCLE_STATES.READY }; |
| 55 | |
| 56 | const VALID_LIMITS = createResourceLimits({ maxRamBytes: 8e9, maxVramBytes: 4e9, maxCpuPercent: 80 }); |
| 57 | const VALID_OBS = { ramBytes: 1e9, vramBytes: 0.5e9, cpuPercent: 10 }; |
| 58 | const VALID_ADMISSION = createAdmissionState({ maxInFlight: 4, queueBound: 8 }); |
| 59 | |
| 60 | // ββ Β§1 RUNTIME_MANAGER_REASONS ββββββββββββββββββββββββββββββββββββββββββββββ |
| 61 | |
| 62 | describe('RUNTIME_MANAGER_REASONS', () => { |
| 63 | it('is frozen (immutable)', () => { |
| 64 | assert.ok(Object.isFrozen(RUNTIME_MANAGER_REASONS)); |
| 65 | }); |
| 66 | |
| 67 | it('contains all expected reason keys', () => { |
| 68 | const expected = [ |
| 69 | 'OK', 'MALFORMED_SPEC', 'SOURCE_NOT_ALLOWED', 'SCHEME_NOT_ALLOWED', |
| 70 | 'SIZE_MISMATCH', 'DIGEST_MISMATCH', 'ACCUMULATOR_FINALIZED', 'ACCUMULATOR_ABORTED', |
| 71 | 'INVALID_TRANSITION', 'NOT_READY', 'UNKNOWN_EVENT', 'UNKNOWN_STATE', |
| 72 | 'MALFORMED_ADMISSION_STATE', 'AT_CAPACITY', 'QUEUE_FULL', 'NO_IN_FLIGHT_TO_COMPLETE', |
| 73 | 'MALFORMED_LIMITS', 'MALFORMED_OBSERVATION', 'RAM_OVER_LIMIT', 'VRAM_OVER_LIMIT', |
| 74 | 'CPU_OVER_LIMIT', 'MALFORMED_REQUEST_PARAMS', |
| 75 | ]; |
| 76 | for (const key of expected) { |
| 77 | assert.ok(Object.prototype.hasOwnProperty.call(RUNTIME_MANAGER_REASONS, key), `missing key: ${key}`); |
| 78 | } |
| 79 | }); |
| 80 | |
| 81 | it('all values are non-empty strings', () => { |
| 82 | for (const [k, v] of Object.entries(RUNTIME_MANAGER_REASONS)) { |
| 83 | assert.equal(typeof v, 'string', `${k} value is not a string`); |
| 84 | assert.ok(v.length > 0, `${k} value is empty`); |
| 85 | } |
| 86 | }); |
| 87 | }); |
| 88 | |
| 89 | // ββ Β§2 validateSourceUrl ββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 90 | |
| 91 | describe('validateSourceUrl', () => { |
| 92 | it('accepts a valid HTTPS URL in the allowlist', () => { |
| 93 | const r = validateSourceUrl(VALID_URL, ALLOWED_URLS); |
| 94 | assert.equal(r.ok, true); |
| 95 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.OK); |
| 96 | }); |
| 97 | |
| 98 | it('rejects non-string URL', () => { |
| 99 | assert.equal(validateSourceUrl(null, ALLOWED_URLS).ok, false); |
| 100 | assert.equal(validateSourceUrl(42, ALLOWED_URLS).ok, false); |
| 101 | }); |
| 102 | |
| 103 | it('rejects empty string URL', () => { |
| 104 | assert.equal(validateSourceUrl('', ALLOWED_URLS).ok, false); |
| 105 | }); |
| 106 | |
| 107 | it('rejects HTTP URL (scheme not allowed)', () => { |
| 108 | const r = validateSourceUrl('http://models.example.com/model.bin', ALLOWED_URLS); |
| 109 | assert.equal(r.ok, false); |
| 110 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.SCHEME_NOT_ALLOWED); |
| 111 | }); |
| 112 | |
| 113 | it('rejects FTP URL', () => { |
| 114 | const r = validateSourceUrl('ftp://models.example.com/model.bin', ALLOWED_URLS); |
| 115 | assert.equal(r.ok, false); |
| 116 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.SCHEME_NOT_ALLOWED); |
| 117 | }); |
| 118 | |
| 119 | it('rejects URL not in allowlist', () => { |
| 120 | const r = validateSourceUrl('https://evil.example.com/model.bin', ALLOWED_URLS); |
| 121 | assert.equal(r.ok, false); |
| 122 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.SOURCE_NOT_ALLOWED); |
| 123 | }); |
| 124 | |
| 125 | it('rejects empty allowlist', () => { |
| 126 | const r = validateSourceUrl(VALID_URL, []); |
| 127 | assert.equal(r.ok, false); |
| 128 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.SOURCE_NOT_ALLOWED); |
| 129 | }); |
| 130 | |
| 131 | it('rejects non-array allowlist', () => { |
| 132 | const r = validateSourceUrl(VALID_URL, null); |
| 133 | assert.equal(r.ok, false); |
| 134 | }); |
| 135 | |
| 136 | it('rejects unparseable URL', () => { |
| 137 | const r = validateSourceUrl('not a url at all \\', ALLOWED_URLS); |
| 138 | assert.equal(r.ok, false); |
| 139 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.MALFORMED_SPEC); |
| 140 | }); |
| 141 | |
| 142 | it('ALLOWED_SOURCE_SCHEMES contains only https:', () => { |
| 143 | assert.deepEqual([...ALLOWED_SOURCE_SCHEMES], ['https:']); |
| 144 | }); |
| 145 | }); |
| 146 | |
| 147 | // ββ Β§3 validateIntegritySpec βββββββββββββββββββββββββββββββββββββββββββββββββ |
| 148 | |
| 149 | describe('validateIntegritySpec', () => { |
| 150 | it('accepts valid digest and size', () => { |
| 151 | const r = validateIntegritySpec(VALID_DIGEST, VALID_SIZE); |
| 152 | assert.equal(r.ok, true); |
| 153 | }); |
| 154 | |
| 155 | it('rejects non-string digest', () => { |
| 156 | assert.equal(validateIntegritySpec(null, VALID_SIZE).ok, false); |
| 157 | }); |
| 158 | |
| 159 | it('rejects digest shorter than 64 chars', () => { |
| 160 | assert.equal(validateIntegritySpec('a'.repeat(63), VALID_SIZE).ok, false); |
| 161 | }); |
| 162 | |
| 163 | it('rejects digest longer than 64 chars', () => { |
| 164 | assert.equal(validateIntegritySpec('a'.repeat(65), VALID_SIZE).ok, false); |
| 165 | }); |
| 166 | |
| 167 | it('rejects uppercase hex chars in digest', () => { |
| 168 | assert.equal(validateIntegritySpec('A'.repeat(64), VALID_SIZE).ok, false); |
| 169 | }); |
| 170 | |
| 171 | it('rejects non-hex chars in digest', () => { |
| 172 | assert.equal(validateIntegritySpec('g'.repeat(64), VALID_SIZE).ok, false); |
| 173 | }); |
| 174 | |
| 175 | it('rejects zero expected size', () => { |
| 176 | assert.equal(validateIntegritySpec(VALID_DIGEST, 0).ok, false); |
| 177 | }); |
| 178 | |
| 179 | it('rejects negative expected size', () => { |
| 180 | assert.equal(validateIntegritySpec(VALID_DIGEST, -1).ok, false); |
| 181 | }); |
| 182 | |
| 183 | it('rejects non-integer expected size', () => { |
| 184 | assert.equal(validateIntegritySpec(VALID_DIGEST, 1.5).ok, false); |
| 185 | }); |
| 186 | |
| 187 | it('SHA256_HEX_LENGTH is 64', () => { |
| 188 | assert.equal(SHA256_HEX_LENGTH, 64); |
| 189 | }); |
| 190 | }); |
| 191 | |
| 192 | // ββ Β§4 createIntegrityAccumulator βββββββββββββββββββββββββββββββββββββββββββ |
| 193 | |
| 194 | describe('createIntegrityAccumulator', () => { |
| 195 | it('throws on malformed spec', () => { |
| 196 | assert.throws(() => createIntegrityAccumulator({ |
| 197 | expectedDigest: 'bad', expectedSizeBytes: VALID_SIZE, |
| 198 | sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS, |
| 199 | })); |
| 200 | }); |
| 201 | |
| 202 | it('throws on bad source URL', () => { |
| 203 | assert.throws(() => createIntegrityAccumulator({ |
| 204 | expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE, |
| 205 | sourceUrl: 'http://models.example.com/model.bin', allowedSourceUrls: ALLOWED_URLS, |
| 206 | })); |
| 207 | }); |
| 208 | |
| 209 | it('accepts valid inputs and returns object with update/finalize/getReceivedBytes/abort', () => { |
| 210 | const acc = createIntegrityAccumulator({ |
| 211 | expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE, |
| 212 | sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS, |
| 213 | }); |
| 214 | assert.equal(typeof acc.update, 'function'); |
| 215 | assert.equal(typeof acc.finalize, 'function'); |
| 216 | assert.equal(typeof acc.getReceivedBytes, 'function'); |
| 217 | assert.equal(typeof acc.abort, 'function'); |
| 218 | }); |
| 219 | |
| 220 | it('returns ok:true for matching data (single chunk)', () => { |
| 221 | const acc = createIntegrityAccumulator({ |
| 222 | expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE, |
| 223 | sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS, |
| 224 | }); |
| 225 | acc.update(VALID_DATA); |
| 226 | const verdict = acc.finalize(); |
| 227 | assert.equal(verdict.ok, true, `expected ok, got: ${verdict.reason}`); |
| 228 | }); |
| 229 | |
| 230 | it('returns ok:true for multi-chunk updates', () => { |
| 231 | const data = Buffer.from('abcdefghijklmnop'); |
| 232 | const digest = makeDigest(data); |
| 233 | const acc = createIntegrityAccumulator({ |
| 234 | expectedDigest: digest, expectedSizeBytes: data.length, |
| 235 | sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS, |
| 236 | }); |
| 237 | acc.update(data.subarray(0, 8)); |
| 238 | acc.update(data.subarray(8)); |
| 239 | const verdict = acc.finalize(); |
| 240 | assert.equal(verdict.ok, true); |
| 241 | }); |
| 242 | |
| 243 | it('returns SIZE_MISMATCH when fewer bytes received', () => { |
| 244 | const acc = createIntegrityAccumulator({ |
| 245 | expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE, |
| 246 | sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS, |
| 247 | }); |
| 248 | acc.update(VALID_DATA.subarray(0, VALID_SIZE - 1)); // one byte short |
| 249 | const verdict = acc.finalize(); |
| 250 | assert.equal(verdict.ok, false); |
| 251 | assert.equal(verdict.reason, RUNTIME_MANAGER_REASONS.SIZE_MISMATCH); |
| 252 | }); |
| 253 | |
| 254 | it('returns DIGEST_MISMATCH when data is wrong but size matches', () => { |
| 255 | const corrupted = Buffer.from('x'.repeat(VALID_SIZE)); // same size, wrong content |
| 256 | const acc = createIntegrityAccumulator({ |
| 257 | expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE, |
| 258 | sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS, |
| 259 | }); |
| 260 | acc.update(corrupted); |
| 261 | const verdict = acc.finalize(); |
| 262 | assert.equal(verdict.ok, false); |
| 263 | assert.equal(verdict.reason, RUNTIME_MANAGER_REASONS.DIGEST_MISMATCH); |
| 264 | }); |
| 265 | |
| 266 | it('returns ACCUMULATOR_FINALIZED on double finalize', () => { |
| 267 | const acc = createIntegrityAccumulator({ |
| 268 | expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE, |
| 269 | sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS, |
| 270 | }); |
| 271 | acc.update(VALID_DATA); |
| 272 | acc.finalize(); |
| 273 | const v2 = acc.finalize(); |
| 274 | assert.equal(v2.ok, false); |
| 275 | assert.equal(v2.reason, RUNTIME_MANAGER_REASONS.ACCUMULATOR_FINALIZED); |
| 276 | }); |
| 277 | |
| 278 | it('returns ACCUMULATOR_ABORTED after abort()', () => { |
| 279 | const acc = createIntegrityAccumulator({ |
| 280 | expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE, |
| 281 | sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS, |
| 282 | }); |
| 283 | acc.abort(); |
| 284 | const verdict = acc.finalize(); |
| 285 | assert.equal(verdict.ok, false); |
| 286 | assert.equal(verdict.reason, RUNTIME_MANAGER_REASONS.ACCUMULATOR_ABORTED); |
| 287 | }); |
| 288 | |
| 289 | it('getReceivedBytes tracks byte count', () => { |
| 290 | const acc = createIntegrityAccumulator({ |
| 291 | expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE, |
| 292 | sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS, |
| 293 | }); |
| 294 | assert.equal(acc.getReceivedBytes(), 0); |
| 295 | acc.update(VALID_DATA.subarray(0, 5)); |
| 296 | assert.equal(acc.getReceivedBytes(), 5); |
| 297 | acc.update(VALID_DATA.subarray(5)); |
| 298 | assert.equal(acc.getReceivedBytes(), VALID_SIZE); |
| 299 | }); |
| 300 | }); |
| 301 | |
| 302 | // ββ Β§5 verifyModelBytes βββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 303 | |
| 304 | describe('verifyModelBytes', () => { |
| 305 | it('accepts matching data', () => { |
| 306 | const r = verifyModelBytes({ |
| 307 | fileData: VALID_DATA, expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE, |
| 308 | sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS, |
| 309 | }); |
| 310 | assert.equal(r.ok, true); |
| 311 | }); |
| 312 | |
| 313 | it('rejects wrong digest', () => { |
| 314 | const wrong = '0'.repeat(64); |
| 315 | const r = verifyModelBytes({ |
| 316 | fileData: VALID_DATA, expectedDigest: wrong, expectedSizeBytes: VALID_SIZE, |
| 317 | sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS, |
| 318 | }); |
| 319 | assert.equal(r.ok, false); |
| 320 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.DIGEST_MISMATCH); |
| 321 | }); |
| 322 | |
| 323 | it('rejects size mismatch', () => { |
| 324 | const r = verifyModelBytes({ |
| 325 | fileData: VALID_DATA, expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE + 1, |
| 326 | sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS, |
| 327 | }); |
| 328 | assert.equal(r.ok, false); |
| 329 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.SIZE_MISMATCH); |
| 330 | }); |
| 331 | |
| 332 | it('rejects HTTP source URL', () => { |
| 333 | const r = verifyModelBytes({ |
| 334 | fileData: VALID_DATA, expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE, |
| 335 | sourceUrl: 'http://models.example.com/model.bin', allowedSourceUrls: ALLOWED_URLS, |
| 336 | }); |
| 337 | assert.equal(r.ok, false); |
| 338 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.SCHEME_NOT_ALLOWED); |
| 339 | }); |
| 340 | |
| 341 | it('rejects non-Buffer fileData', () => { |
| 342 | const r = verifyModelBytes({ |
| 343 | fileData: 'not-a-buffer', expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE, |
| 344 | sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS, |
| 345 | }); |
| 346 | assert.equal(r.ok, false); |
| 347 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.MALFORMED_SPEC); |
| 348 | }); |
| 349 | }); |
| 350 | |
| 351 | // ββ Β§6 Lifecycle state machine βββββββββββββββββββββββββββββββββββββββββββββββ |
| 352 | |
| 353 | describe('createLifecycleState', () => { |
| 354 | it('returns stopped state initially', () => { |
| 355 | const s = createLifecycleState(); |
| 356 | assert.equal(s.state, LIFECYCLE_STATES.STOPPED); |
| 357 | }); |
| 358 | }); |
| 359 | |
| 360 | describe('transitionLifecycle', () => { |
| 361 | it('stopped β starting on start event', () => { |
| 362 | const s = createLifecycleState(); |
| 363 | const r = transitionLifecycle(s, LIFECYCLE_EVENTS.START); |
| 364 | assert.equal(r.ok, true); |
| 365 | assert.equal(r.newState.state, LIFECYCLE_STATES.STARTING); |
| 366 | }); |
| 367 | |
| 368 | it('starting β ready on health_ok', () => { |
| 369 | const s = { state: LIFECYCLE_STATES.STARTING }; |
| 370 | const r = transitionLifecycle(s, LIFECYCLE_EVENTS.HEALTH_OK); |
| 371 | assert.equal(r.ok, true); |
| 372 | assert.equal(r.newState.state, LIFECYCLE_STATES.READY); |
| 373 | }); |
| 374 | |
| 375 | it('starting β stopped on health_fail', () => { |
| 376 | const s = { state: LIFECYCLE_STATES.STARTING }; |
| 377 | const r = transitionLifecycle(s, LIFECYCLE_EVENTS.HEALTH_FAIL); |
| 378 | assert.equal(r.ok, true); |
| 379 | assert.equal(r.newState.state, LIFECYCLE_STATES.STOPPED); |
| 380 | }); |
| 381 | |
| 382 | it('ready β draining on drain', () => { |
| 383 | const s = { state: LIFECYCLE_STATES.READY }; |
| 384 | const r = transitionLifecycle(s, LIFECYCLE_EVENTS.DRAIN); |
| 385 | assert.equal(r.ok, true); |
| 386 | assert.equal(r.newState.state, LIFECYCLE_STATES.DRAINING); |
| 387 | }); |
| 388 | |
| 389 | it('draining β stopped on stopped', () => { |
| 390 | const s = { state: LIFECYCLE_STATES.DRAINING }; |
| 391 | const r = transitionLifecycle(s, LIFECYCLE_EVENTS.STOPPED); |
| 392 | assert.equal(r.ok, true); |
| 393 | assert.equal(r.newState.state, LIFECYCLE_STATES.STOPPED); |
| 394 | }); |
| 395 | |
| 396 | it('rejects invalid transition (stopped + health_ok)', () => { |
| 397 | const s = createLifecycleState(); |
| 398 | const r = transitionLifecycle(s, LIFECYCLE_EVENTS.HEALTH_OK); |
| 399 | assert.equal(r.ok, false); |
| 400 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.INVALID_TRANSITION); |
| 401 | }); |
| 402 | |
| 403 | it('rejects invalid transition (ready + health_ok)', () => { |
| 404 | const r = transitionLifecycle({ state: LIFECYCLE_STATES.READY }, LIFECYCLE_EVENTS.HEALTH_OK); |
| 405 | assert.equal(r.ok, false); |
| 406 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.INVALID_TRANSITION); |
| 407 | }); |
| 408 | |
| 409 | it('rejects unknown event', () => { |
| 410 | const s = createLifecycleState(); |
| 411 | const r = transitionLifecycle(s, 'nonexistent_event'); |
| 412 | assert.equal(r.ok, false); |
| 413 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.INVALID_TRANSITION); |
| 414 | }); |
| 415 | |
| 416 | it('rejects null event', () => { |
| 417 | const s = createLifecycleState(); |
| 418 | const r = transitionLifecycle(s, null); |
| 419 | assert.equal(r.ok, false); |
| 420 | }); |
| 421 | |
| 422 | it('rejects malformed state object', () => { |
| 423 | const r = transitionLifecycle(null, LIFECYCLE_EVENTS.START); |
| 424 | assert.equal(r.ok, false); |
| 425 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.UNKNOWN_STATE); |
| 426 | }); |
| 427 | |
| 428 | it('does not mutate input state', () => { |
| 429 | const s = createLifecycleState(); |
| 430 | transitionLifecycle(s, LIFECYCLE_EVENTS.START); |
| 431 | assert.equal(s.state, LIFECYCLE_STATES.STOPPED); // unchanged |
| 432 | }); |
| 433 | }); |
| 434 | |
| 435 | describe('canServeInference', () => { |
| 436 | it('returns true only for ready state', () => { |
| 437 | assert.equal(canServeInference({ state: LIFECYCLE_STATES.READY }), true); |
| 438 | }); |
| 439 | |
| 440 | it('returns false for stopped', () => { |
| 441 | assert.equal(canServeInference({ state: LIFECYCLE_STATES.STOPPED }), false); |
| 442 | }); |
| 443 | |
| 444 | it('returns false for starting', () => { |
| 445 | assert.equal(canServeInference({ state: LIFECYCLE_STATES.STARTING }), false); |
| 446 | }); |
| 447 | |
| 448 | it('returns false for draining', () => { |
| 449 | assert.equal(canServeInference({ state: LIFECYCLE_STATES.DRAINING }), false); |
| 450 | }); |
| 451 | |
| 452 | it('returns false for null', () => { |
| 453 | assert.equal(canServeInference(null), false); |
| 454 | }); |
| 455 | |
| 456 | it('returns false for empty object', () => { |
| 457 | assert.equal(canServeInference({}), false); |
| 458 | }); |
| 459 | |
| 460 | it('returns false for unknown state string', () => { |
| 461 | assert.equal(canServeInference({ state: 'unknown' }), false); |
| 462 | }); |
| 463 | }); |
| 464 | |
| 465 | // ββ Β§7 Admission βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 466 | |
| 467 | describe('createAdmissionState', () => { |
| 468 | it('creates state with correct fields', () => { |
| 469 | const s = createAdmissionState({ maxInFlight: 4, queueBound: 8 }); |
| 470 | assert.equal(s.maxInFlight, 4); |
| 471 | assert.equal(s.queueBound, 8); |
| 472 | assert.equal(s.inFlight, 0); |
| 473 | assert.equal(s.queued, 0); |
| 474 | }); |
| 475 | |
| 476 | it('throws on non-integer maxInFlight', () => { |
| 477 | assert.throws(() => createAdmissionState({ maxInFlight: 1.5, queueBound: 8 })); |
| 478 | }); |
| 479 | |
| 480 | it('throws on zero maxInFlight', () => { |
| 481 | assert.throws(() => createAdmissionState({ maxInFlight: 0, queueBound: 8 })); |
| 482 | }); |
| 483 | |
| 484 | it('throws on negative queueBound', () => { |
| 485 | assert.throws(() => createAdmissionState({ maxInFlight: 4, queueBound: -1 })); |
| 486 | }); |
| 487 | }); |
| 488 | |
| 489 | describe('evaluateAdmission', () => { |
| 490 | it('allows when under max in-flight', () => { |
| 491 | const s = createAdmissionState({ maxInFlight: 4, queueBound: 8 }); |
| 492 | const r = evaluateAdmission(s); |
| 493 | assert.equal(r.ok, true); |
| 494 | }); |
| 495 | |
| 496 | it('returns AT_CAPACITY when all slots full but queue has room', () => { |
| 497 | let s = createAdmissionState({ maxInFlight: 2, queueBound: 4 }); |
| 498 | s = { ...s, inFlight: 2 }; |
| 499 | const r = evaluateAdmission(s); |
| 500 | assert.equal(r.ok, false); |
| 501 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.AT_CAPACITY); |
| 502 | }); |
| 503 | |
| 504 | it('returns QUEUE_FULL when both in-flight and queue are full', () => { |
| 505 | let s = createAdmissionState({ maxInFlight: 2, queueBound: 4 }); |
| 506 | s = { ...s, inFlight: 2, queued: 4 }; |
| 507 | const r = evaluateAdmission(s); |
| 508 | assert.equal(r.ok, false); |
| 509 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.QUEUE_FULL); |
| 510 | }); |
| 511 | |
| 512 | it('returns MALFORMED_ADMISSION_STATE for null', () => { |
| 513 | const r = evaluateAdmission(null); |
| 514 | assert.equal(r.ok, false); |
| 515 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.MALFORMED_ADMISSION_STATE); |
| 516 | }); |
| 517 | }); |
| 518 | |
| 519 | describe('recordInFlight / recordCompletion', () => { |
| 520 | it('increments inFlight on recordInFlight', () => { |
| 521 | const s = createAdmissionState({ maxInFlight: 4, queueBound: 8 }); |
| 522 | const s2 = recordInFlight(s); |
| 523 | assert.equal(s2.inFlight, 1); |
| 524 | assert.equal(s.inFlight, 0); // original unchanged |
| 525 | }); |
| 526 | |
| 527 | it('decrements inFlight on recordCompletion', () => { |
| 528 | let s = createAdmissionState({ maxInFlight: 4, queueBound: 8 }); |
| 529 | s = recordInFlight(s); |
| 530 | s = recordCompletion(s); |
| 531 | assert.equal(s.inFlight, 0); |
| 532 | }); |
| 533 | |
| 534 | it('throws on recordCompletion when no in-flight', () => { |
| 535 | const s = createAdmissionState({ maxInFlight: 4, queueBound: 8 }); |
| 536 | assert.throws(() => recordCompletion(s)); |
| 537 | }); |
| 538 | }); |
| 539 | |
| 540 | describe('recordQueued / recordDequeued', () => { |
| 541 | it('increments queued on recordQueued', () => { |
| 542 | const s = createAdmissionState({ maxInFlight: 4, queueBound: 8 }); |
| 543 | const s2 = recordQueued(s); |
| 544 | assert.equal(s2.queued, 1); |
| 545 | assert.equal(s.queued, 0); // original unchanged |
| 546 | }); |
| 547 | |
| 548 | it('decrements queued on recordDequeued', () => { |
| 549 | let s = createAdmissionState({ maxInFlight: 4, queueBound: 8 }); |
| 550 | s = recordQueued(s); |
| 551 | s = recordDequeued(s); |
| 552 | assert.equal(s.queued, 0); |
| 553 | }); |
| 554 | |
| 555 | it('throws on recordDequeued when no queued', () => { |
| 556 | const s = createAdmissionState({ maxInFlight: 4, queueBound: 8 }); |
| 557 | assert.throws(() => recordDequeued(s)); |
| 558 | }); |
| 559 | }); |
| 560 | |
| 561 | // ββ Β§8 Resource limits βββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 562 | |
| 563 | describe('createResourceLimits', () => { |
| 564 | it('creates valid limits', () => { |
| 565 | const limits = createResourceLimits({ maxRamBytes: 8e9, maxVramBytes: 4e9, maxCpuPercent: 80 }); |
| 566 | assert.equal(limits.maxRamBytes, 8e9); |
| 567 | assert.equal(limits.maxVramBytes, 4e9); |
| 568 | assert.equal(limits.maxCpuPercent, 80); |
| 569 | }); |
| 570 | |
| 571 | it('throws on non-finite RAM limit', () => { |
| 572 | assert.throws(() => createResourceLimits({ maxRamBytes: NaN, maxVramBytes: 4e9, maxCpuPercent: 80 })); |
| 573 | }); |
| 574 | |
| 575 | it('throws on zero VRAM limit', () => { |
| 576 | assert.throws(() => createResourceLimits({ maxRamBytes: 8e9, maxVramBytes: 0, maxCpuPercent: 80 })); |
| 577 | }); |
| 578 | |
| 579 | it('throws on CPU percent over 100', () => { |
| 580 | assert.throws(() => createResourceLimits({ maxRamBytes: 8e9, maxVramBytes: 4e9, maxCpuPercent: 101 })); |
| 581 | }); |
| 582 | |
| 583 | it('throws on zero CPU percent', () => { |
| 584 | assert.throws(() => createResourceLimits({ maxRamBytes: 8e9, maxVramBytes: 4e9, maxCpuPercent: 0 })); |
| 585 | }); |
| 586 | }); |
| 587 | |
| 588 | describe('evaluateResourceLimits', () => { |
| 589 | it('allows when all metrics under limits', () => { |
| 590 | const r = evaluateResourceLimits(VALID_OBS, VALID_LIMITS); |
| 591 | assert.equal(r.ok, true); |
| 592 | }); |
| 593 | |
| 594 | it('returns RAM_OVER_LIMIT when RAM exceeded', () => { |
| 595 | const obs = { ...VALID_OBS, ramBytes: VALID_LIMITS.maxRamBytes + 1 }; |
| 596 | const r = evaluateResourceLimits(obs, VALID_LIMITS); |
| 597 | assert.equal(r.ok, false); |
| 598 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.RAM_OVER_LIMIT); |
| 599 | }); |
| 600 | |
| 601 | it('returns VRAM_OVER_LIMIT when VRAM exceeded', () => { |
| 602 | const obs = { ...VALID_OBS, vramBytes: VALID_LIMITS.maxVramBytes + 1 }; |
| 603 | const r = evaluateResourceLimits(obs, VALID_LIMITS); |
| 604 | assert.equal(r.ok, false); |
| 605 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.VRAM_OVER_LIMIT); |
| 606 | }); |
| 607 | |
| 608 | it('returns CPU_OVER_LIMIT when CPU exceeded', () => { |
| 609 | const obs = { ...VALID_OBS, cpuPercent: VALID_LIMITS.maxCpuPercent + 1 }; |
| 610 | const r = evaluateResourceLimits(obs, VALID_LIMITS); |
| 611 | assert.equal(r.ok, false); |
| 612 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.CPU_OVER_LIMIT); |
| 613 | }); |
| 614 | |
| 615 | it('returns MALFORMED_LIMITS for null limits', () => { |
| 616 | const r = evaluateResourceLimits(VALID_OBS, null); |
| 617 | assert.equal(r.ok, false); |
| 618 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.MALFORMED_LIMITS); |
| 619 | }); |
| 620 | |
| 621 | it('returns MALFORMED_OBSERVATION for null observation', () => { |
| 622 | const r = evaluateResourceLimits(null, VALID_LIMITS); |
| 623 | assert.equal(r.ok, false); |
| 624 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.MALFORMED_OBSERVATION); |
| 625 | }); |
| 626 | |
| 627 | it('allows RAM exactly at limit (not over)', () => { |
| 628 | const obs = { ...VALID_OBS, ramBytes: VALID_LIMITS.maxRamBytes }; |
| 629 | const r = evaluateResourceLimits(obs, VALID_LIMITS); |
| 630 | assert.equal(r.ok, true); |
| 631 | }); |
| 632 | }); |
| 633 | |
| 634 | // ββ Β§9 evaluateRuntimeRequest ββββββββββββββββββββββββββββββββββββββββββββββββ |
| 635 | |
| 636 | describe('evaluateRuntimeRequest', () => { |
| 637 | it('allows when all gates pass', () => { |
| 638 | const r = evaluateRuntimeRequest({ |
| 639 | lifecycleState: READY_STATE, |
| 640 | admissionState: VALID_ADMISSION, |
| 641 | resourceObservation: VALID_OBS, |
| 642 | resourceLimits: VALID_LIMITS, |
| 643 | }); |
| 644 | assert.equal(r.ok, true); |
| 645 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.OK); |
| 646 | }); |
| 647 | |
| 648 | it('returns NOT_READY when lifecycle is stopped', () => { |
| 649 | const r = evaluateRuntimeRequest({ |
| 650 | lifecycleState: createLifecycleState(), |
| 651 | admissionState: VALID_ADMISSION, |
| 652 | resourceObservation: VALID_OBS, |
| 653 | resourceLimits: VALID_LIMITS, |
| 654 | }); |
| 655 | assert.equal(r.ok, false); |
| 656 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.NOT_READY); |
| 657 | }); |
| 658 | |
| 659 | it('returns NOT_READY when lifecycle is starting', () => { |
| 660 | const r = evaluateRuntimeRequest({ |
| 661 | lifecycleState: { state: LIFECYCLE_STATES.STARTING }, |
| 662 | admissionState: VALID_ADMISSION, |
| 663 | resourceObservation: VALID_OBS, |
| 664 | resourceLimits: VALID_LIMITS, |
| 665 | }); |
| 666 | assert.equal(r.ok, false); |
| 667 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.NOT_READY); |
| 668 | }); |
| 669 | |
| 670 | it('returns QUEUE_FULL when admission gate is exhausted', () => { |
| 671 | const fullState = { ...VALID_ADMISSION, inFlight: VALID_ADMISSION.maxInFlight, queued: VALID_ADMISSION.queueBound }; |
| 672 | const r = evaluateRuntimeRequest({ |
| 673 | lifecycleState: READY_STATE, |
| 674 | admissionState: fullState, |
| 675 | resourceObservation: VALID_OBS, |
| 676 | resourceLimits: VALID_LIMITS, |
| 677 | }); |
| 678 | assert.equal(r.ok, false); |
| 679 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.QUEUE_FULL); |
| 680 | }); |
| 681 | |
| 682 | it('returns RAM_OVER_LIMIT when RAM is over limit', () => { |
| 683 | const obs = { ...VALID_OBS, ramBytes: VALID_LIMITS.maxRamBytes + 1 }; |
| 684 | const r = evaluateRuntimeRequest({ |
| 685 | lifecycleState: READY_STATE, |
| 686 | admissionState: VALID_ADMISSION, |
| 687 | resourceObservation: obs, |
| 688 | resourceLimits: VALID_LIMITS, |
| 689 | }); |
| 690 | assert.equal(r.ok, false); |
| 691 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.RAM_OVER_LIMIT); |
| 692 | }); |
| 693 | |
| 694 | it('returns MALFORMED_REQUEST_PARAMS when called with no params', () => { |
| 695 | const r = evaluateRuntimeRequest(); |
| 696 | assert.equal(r.ok, false); |
| 697 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.MALFORMED_REQUEST_PARAMS); |
| 698 | }); |
| 699 | |
| 700 | it('returns MALFORMED_REQUEST_PARAMS when any param is null', () => { |
| 701 | const r = evaluateRuntimeRequest({ |
| 702 | lifecycleState: READY_STATE, |
| 703 | admissionState: null, |
| 704 | resourceObservation: VALID_OBS, |
| 705 | resourceLimits: VALID_LIMITS, |
| 706 | }); |
| 707 | assert.equal(r.ok, false); |
| 708 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.MALFORMED_REQUEST_PARAMS); |
| 709 | }); |
| 710 | |
| 711 | it('lifecycle gate is checked before admission gate', () => { |
| 712 | const fullAdmission = { ...VALID_ADMISSION, inFlight: VALID_ADMISSION.maxInFlight, queued: VALID_ADMISSION.queueBound }; |
| 713 | const r = evaluateRuntimeRequest({ |
| 714 | lifecycleState: { state: LIFECYCLE_STATES.STOPPED }, |
| 715 | admissionState: fullAdmission, |
| 716 | resourceObservation: VALID_OBS, |
| 717 | resourceLimits: VALID_LIMITS, |
| 718 | }); |
| 719 | // Should be NOT_READY (lifecycle), not QUEUE_FULL (admission) |
| 720 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.NOT_READY); |
| 721 | }); |
| 722 | |
| 723 | it('admission gate is checked before resource gate', () => { |
| 724 | const fullAdmission = { ...VALID_ADMISSION, inFlight: VALID_ADMISSION.maxInFlight, queued: VALID_ADMISSION.queueBound }; |
| 725 | const obs = { ...VALID_OBS, ramBytes: VALID_LIMITS.maxRamBytes + 1 }; |
| 726 | const r = evaluateRuntimeRequest({ |
| 727 | lifecycleState: READY_STATE, |
| 728 | admissionState: fullAdmission, |
| 729 | resourceObservation: obs, |
| 730 | resourceLimits: VALID_LIMITS, |
| 731 | }); |
| 732 | // Should be QUEUE_FULL (admission), not RAM_OVER_LIMIT (resource) |
| 733 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.QUEUE_FULL); |
| 734 | }); |
| 735 | }); |