@openmixer/plugin-qualify 0.17.5 → 0.18.1

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.
Files changed (109) hide show
  1. package/README.md +127 -13
  2. package/bin/plugin-qualify-sync.mjs +3 -1
  3. package/dist/clap-qualify.d.ts +3 -1
  4. package/dist/clap-qualify.js +7 -1
  5. package/dist/clap-qualify.js.map +1 -1
  6. package/dist/direct-limit.d.ts +27 -0
  7. package/dist/direct-limit.js +52 -0
  8. package/dist/direct-limit.js.map +1 -0
  9. package/dist/distributed-records.d.ts +4 -2
  10. package/dist/distributed-records.js +1 -1
  11. package/dist/distributed-records.js.map +1 -1
  12. package/dist/host-admission.d.ts +12 -2
  13. package/dist/host-admission.js +24 -4
  14. package/dist/host-admission.js.map +1 -1
  15. package/dist/host-profiles.d.ts +8 -0
  16. package/dist/host-profiles.js +16 -1
  17. package/dist/host-profiles.js.map +1 -1
  18. package/dist/hosting-codes.generated.d.ts +10 -0
  19. package/dist/hosting-codes.generated.js +10 -0
  20. package/dist/hosting-codes.generated.js.map +1 -1
  21. package/dist/hosting-suitability.d.ts +34 -8
  22. package/dist/hosting-suitability.js +92 -7
  23. package/dist/hosting-suitability.js.map +1 -1
  24. package/dist/index.d.ts +3 -0
  25. package/dist/index.js +3 -0
  26. package/dist/index.js.map +1 -1
  27. package/dist/measurement/format.d.ts +54 -8
  28. package/dist/measurement/format.js +54 -3
  29. package/dist/measurement/format.js.map +1 -1
  30. package/dist/measurement/plan.d.ts +3 -3
  31. package/dist/measurement/plan.js +1 -1
  32. package/dist/measurement-grid.d.ts +8 -0
  33. package/dist/measurement-grid.js +4 -0
  34. package/dist/measurement-grid.js.map +1 -1
  35. package/dist/memory-bound.d.ts +6 -0
  36. package/dist/memory-bound.js +8 -0
  37. package/dist/memory-bound.js.map +1 -1
  38. package/dist/memory-budget.d.ts +39 -0
  39. package/dist/memory-budget.js +32 -0
  40. package/dist/memory-budget.js.map +1 -0
  41. package/dist/probe-judge.d.ts +11 -0
  42. package/dist/probe-judge.js +26 -10
  43. package/dist/probe-judge.js.map +1 -1
  44. package/dist/probe-merge.js +6 -5
  45. package/dist/probe-merge.js.map +1 -1
  46. package/dist/probe-output-fixtures.d.ts +6 -0
  47. package/dist/probe-output-fixtures.js +8 -0
  48. package/dist/probe-output-fixtures.js.map +1 -1
  49. package/dist/probe-output.d.ts +4 -2
  50. package/dist/probe-output.js +2 -1
  51. package/dist/probe-output.js.map +1 -1
  52. package/dist/probe-runner.js +7 -3
  53. package/dist/probe-runner.js.map +1 -1
  54. package/dist/qualification-record.d.ts +116 -10
  55. package/dist/qualification-record.js +34 -30
  56. package/dist/qualification-record.js.map +1 -1
  57. package/dist/qualify-adapter-pass.d.ts +67 -0
  58. package/dist/qualify-adapter-pass.js +255 -0
  59. package/dist/qualify-adapter-pass.js.map +1 -0
  60. package/dist/qualify-declarations.d.ts +53 -9
  61. package/dist/qualify-declarations.js +54 -2
  62. package/dist/qualify-declarations.js.map +1 -1
  63. package/dist/qualify-rejudge.d.ts +81 -0
  64. package/dist/qualify-rejudge.js +421 -0
  65. package/dist/qualify-rejudge.js.map +1 -0
  66. package/dist/qualify-sync.d.ts +72 -1
  67. package/dist/qualify-sync.js +194 -21
  68. package/dist/qualify-sync.js.map +1 -1
  69. package/fixtures/records-0.16.1/http___example.org_raffo@unversioned-2060c09ff626.default.probe.json +46 -0
  70. package/fixtures/records-0.16.1/http___example.org_raffo@unversioned-2060c09ff626.default.qualification.json +185 -0
  71. package/fixtures/records-0.16.1/http___lv2plug.in_plugins_eg-amp@unversioned-f046a60efd45.default.probe.json +51 -0
  72. package/fixtures/records-0.16.1/http___lv2plug.in_plugins_eg-amp@unversioned-f046a60efd45.default.qualification.json +232 -0
  73. package/fixtures/records-0.16.1/https___github.com_lucianodato_noise-repellent@unversioned-c3b5e21dd9a7.default.probe.json +51 -0
  74. package/fixtures/records-0.16.1/https___github.com_lucianodato_noise-repellent@unversioned-c3b5e21dd9a7.default.qualification.json +245 -0
  75. package/fixtures/records-0.16.1/in.lsp-plug.impulse_responses_mono@unversioned-d5235d2f5f53.custom.probe.json +52 -0
  76. package/fixtures/records-0.16.1/in.lsp-plug.impulse_responses_mono@unversioned-d5235d2f5f53.custom.qualification.json +145 -0
  77. package/fixtures/records-0.16.1/in.lsp-plug.trigger_midi_mono@unversioned-d5235d2f5f53.custom.probe.json +52 -0
  78. package/fixtures/records-0.16.1/in.lsp-plug.trigger_midi_mono@unversioned-d5235d2f5f53.custom.qualification.json +155 -0
  79. package/package.json +1 -1
  80. package/src/clap-qualify.ts +8 -1
  81. package/src/direct-limit.ts +68 -0
  82. package/src/distributed-records.ts +1 -1
  83. package/src/host-admission.ts +27 -4
  84. package/src/host-profiles.ts +17 -1
  85. package/src/hosting-codes.generated.ts +10 -0
  86. package/src/hosting-suitability.ts +105 -11
  87. package/src/index.ts +3 -0
  88. package/src/measurement/format.ts +97 -3
  89. package/src/measurement/plan.ts +3 -3
  90. package/src/measurement-grid.ts +5 -0
  91. package/src/memory-bound.ts +10 -0
  92. package/src/memory-budget.ts +55 -0
  93. package/src/probe-judge.ts +29 -9
  94. package/src/probe-merge.ts +6 -5
  95. package/src/probe-output-fixtures.ts +10 -1
  96. package/src/probe-output.ts +5 -2
  97. package/src/probe-runner.ts +5 -3
  98. package/src/qualification-record.ts +121 -18
  99. package/src/qualify-adapter-pass.ts +278 -0
  100. package/src/qualify-declarations.ts +67 -3
  101. package/src/qualify-rejudge.ts +452 -0
  102. package/src/qualify-sync.ts +210 -20
  103. package/tools/_declarations.py +1 -0
  104. package/tools/benchmark.py +25 -2
  105. package/tools/gen-python-declarations.mjs +1 -0
  106. package/tools/lv2-measure.mjs +7 -4
  107. package/tools/lv2_presets.py +1 -1
  108. package/tools/lv2_resolve.py +49 -0
  109. package/tools/mod-host-admit.mjs +23 -6
@@ -0,0 +1,145 @@
1
+ {
2
+ "schema": "plugin-qualify.qualification-record/1",
3
+ "plugin": {
4
+ "format": "clap",
5
+ "id": "in.lsp-plug.impulse_responses_mono"
6
+ },
7
+ "binary": {
8
+ "path": "/usr/lib/clap/lsp-plugins.clap",
9
+ "sha256": "d5235d2f5f53eb3641dc9225ff5a912b5eae718cd0abbecd46350dba666f19a2",
10
+ "files": [
11
+ {
12
+ "path": "lsp-plugins.clap",
13
+ "sha256": "d5235d2f5f53eb3641dc9225ff5a912b5eae718cd0abbecd46350dba666f19a2"
14
+ }
15
+ ],
16
+ "layout": {
17
+ "scheme": "omx-layout/1",
18
+ "sha256": "86f71f835f0db0150b6b219b738b97ae0d9d423d2305e29ebdb172b8e20a6381"
19
+ }
20
+ },
21
+ "verdicts": [
22
+ {
23
+ "host": "omx-clap-host",
24
+ "measured": true,
25
+ "rating": "unsuitable",
26
+ "qualified": false,
27
+ "hosting": "isolated",
28
+ "arm": "declaration",
29
+ "deciding": "hosting.rt-safety.violations-in-run",
30
+ "reasons": [
31
+ {
32
+ "dimension": "rtSafety",
33
+ "rating": "unsuitable",
34
+ "code": "hosting.rt-safety.violations-in-run",
35
+ "params": {
36
+ "allocations": 0,
37
+ "syscalls": 1,
38
+ "locks": 0
39
+ }
40
+ }
41
+ ]
42
+ }
43
+ ],
44
+ "tiers": {
45
+ "isolated": {
46
+ "host": "omx-clap-host",
47
+ "measured": true,
48
+ "qualified": false,
49
+ "rating": "unsuitable",
50
+ "deciding": "hosting.rt-safety.violations-in-run"
51
+ },
52
+ "offeredOn": [],
53
+ "dimmed": "omx-clap-host: hosting.rt-safety.violations-in-run"
54
+ },
55
+ "cost": [
56
+ {
57
+ "host": "omx-clap-host",
58
+ "rate": 48000,
59
+ "quantum": 128,
60
+ "reasons": [
61
+ {
62
+ "dimension": "cost",
63
+ "rating": "unknown",
64
+ "code": "hosting.cost.unmeasured-at-rate-and-quantum",
65
+ "params": {
66
+ "rate": 48000,
67
+ "quantum": 128
68
+ }
69
+ }
70
+ ]
71
+ }
72
+ ],
73
+ "measurements": {
74
+ "grid": [
75
+ {
76
+ "rate": 48000,
77
+ "quantum": 512,
78
+ "outcome": "probe.ok",
79
+ "latency": {
80
+ "declaredFrames": 0,
81
+ "measuredFrames": 0
82
+ },
83
+ "cost": {
84
+ "median": 213.506,
85
+ "percentile": {
86
+ "p": 95,
87
+ "value": 230.143
88
+ },
89
+ "blocks": 512,
90
+ "warmupBlocks": 64
91
+ },
92
+ "rtSafety": {
93
+ "interposer": true,
94
+ "swept": true,
95
+ "sweepApplicable": true,
96
+ "blocks": 512,
97
+ "allocations": 0,
98
+ "locks": 0,
99
+ "syscalls": 1,
100
+ "threadViolations": 0,
101
+ "processErrors": 0,
102
+ "events": [
103
+ "nanosleep"
104
+ ]
105
+ },
106
+ "noteSweep": {
107
+ "applicable": false
108
+ }
109
+ }
110
+ ]
111
+ },
112
+ "notMeasured": [
113
+ {
114
+ "dimension": "stability",
115
+ "code": "hosting.stability.unsoaked"
116
+ }
117
+ ],
118
+ "tool": {
119
+ "runner": "plugin-hostd-probe 0.13.1",
120
+ "child": "clap-qualify",
121
+ "interposer": "rt_interpose.so"
122
+ },
123
+ "measuredAt": "2026-10-28T20:56:58+00:00",
124
+ "probe": {
125
+ "file": "in.lsp-plug.impulse_responses_mono@unversioned-d5235d2f5f53.custom.probe.json",
126
+ "sha256": "bc818dfeff46238de3b5558ab8d54aa5677a18f6e3cad79c67bc117313d0a79d",
127
+ "bytes": 3894,
128
+ "protocol": {
129
+ "name": "plugin-hostd.probe-protocol",
130
+ "version": 1
131
+ },
132
+ "reason": "on-request",
133
+ "outcome": "probe.ok",
134
+ "complete": true,
135
+ "machineClass": {
136
+ "arch": "aarch64",
137
+ "family": "arm",
138
+ "coreBand": "3-4",
139
+ "clockBand": "1.5-2.5GHz",
140
+ "preemptRt": false,
141
+ "cstatesLimited": false
142
+ },
143
+ "meetsProtocol": false
144
+ }
145
+ }
@@ -0,0 +1,52 @@
1
+ {
2
+ "format": "plugin-hostd.probe-output",
3
+ "formatVersion": "1.1",
4
+ "run": {
5
+ "id": "20261028T210152Z-321dd-bb8cc3c7",
6
+ "startedAt": "2026-10-28T21:01:52+00:00",
7
+ "elapsedMs": 332,
8
+ "complete": true,
9
+ "outcome": "probe.ok",
10
+ "reason": "on-request",
11
+ "protocol": {"name": "plugin-hostd.probe-protocol", "version": 1},
12
+ "tools": {"runner": "plugin-hostd-probe 0.13.1", "child": "clap-qualify", "interposer": "rt_interpose.so"},
13
+ "via": "runner"
14
+ },
15
+ "plugin": {"format": "clap", "id": "in.lsp-plug.trigger_midi_mono"},
16
+ "binary": {"path": "/usr/lib/clap/lsp-plugins.clap", "sha256": "d5235d2f5f53eb3641dc9225ff5a912b5eae718cd0abbecd46350dba666f19a2", "files": [{"path": "lsp-plugins.clap", "sha256": "d5235d2f5f53eb3641dc9225ff5a912b5eae718cd0abbecd46350dba666f19a2"}], "layout": {"scheme": "omx-layout/1", "sha256": "5a2b073f777379e75dcd931ff9930352d12dcfab9e1fb599d3b1d45d62fe3e1d"}},
17
+ "machine": {
18
+ "class": {"arch": "aarch64", "family": "arm", "coreBand": "3-4", "clockBand": "1.5-2.5GHz", "preemptRt": false, "cstatesLimited": false},
19
+ "profile": {"governor": "powersave", "turbo": false, "smt": false, "preempt": "PREEMPT",
20
+ "cpuidle": [],
21
+ "meetsProtocol": false, "unmet": ["probe.profile.governor", "probe.profile.not-pinned"]},
22
+ "pinnedCpus": "0-3"
23
+ },
24
+ "offer": {"id": "custom", "clapExtensions": ["clap.log", "clap.thread-check", "clap.latency", "clap.params", "clap.audio-ports", "clap.state", "clap.note-ports", "clap.note-name", "clap.voice-info", "clap.event-registry", "clap.timer-support", "clap.posix-fd-support", "clap.thread-pool", "clap.tail", "clap.surround/4", "clap.ambisonic/3", "clap.audio-ports-config", "clap.preset-load/2", "clap.track-info/1", "clap.remote-controls/2"], "realtime": "none"},
25
+ "limits": {"set": "default",
26
+ "applied": [
27
+ {"limit": "wall-clock", "value": 60, "unit": "s", "by": "supervisor"},
28
+ {"limit": "cpu-time", "value": 540, "unit": "s", "by": "rlimit"},
29
+ {"limit": "memory", "value": 4294967296, "unit": "bytes", "by": "rlimit"},
30
+ {"limit": "files", "value": 256, "unit": "count", "by": "rlimit"},
31
+ {"limit": "file-size", "value": 67108864, "unit": "bytes", "by": "rlimit"},
32
+ {"limit": "core-dump", "value": 0, "unit": "bytes", "by": "rlimit"},
33
+ {"limit": "rt-priority", "value": 0, "by": "rlimit"},
34
+ {"limit": "no-new-privs", "by": "prctl"}
35
+ ],
36
+ "unavailable": [
37
+ {"limit": "tasks", "code": "probe.limit.not-implemented"}
38
+ ]
39
+ },
40
+ "steps": [
41
+ {"step": "hash", "outcome": "probe.ok", "elapsedMs": 0},
42
+ {"step": "lifecycle", "outcome": "probe.ok", "elapsedMs": 332, "exitStatus": 0},
43
+ {"step": "instantiate", "outcome": "probe.ok", "elapsedMs": 0, "detail": "read from the child's line"},
44
+ {"step": "latency", "rate": 48000, "quantum": 512, "outcome": "probe.ok", "elapsedMs": 0, "detail": "read from the child's line"},
45
+ {"step": "rt-safety", "outcome": "probe.ok", "elapsedMs": 0, "detail": "counts are totals over every rate, read from the child's line"},
46
+ {"step": "note-sweep", "outcome": "probe.ok", "elapsedMs": 0, "detail": "counts are totals over every rate, read from the child's line"}
47
+ ],
48
+ "grid": [
49
+ {"rate": 48000, "quantum": 512, "outcome": "probe.ok", "latency": {"declaredFrames": 0, "measuredFrames": 0, "method": "impulse-onset"}, "cost": {"unit": "ns-per-sample", "median": 47.9961, "percentile": {"p": 95, "value": 57.2365}, "min": 31.5742, "max": 114.258, "blocks": 512, "warmupBlocks": 64}, "rtSafety": {"interposer": true, "swept": true, "sweepApplicable": true, "blocks": 512, "allocations": 0, "locks": 0, "syscalls": 0, "threadViolations": 0, "processErrors": 0, "events": []}, "noteSweep": {"applicable": true, "dialect": "midi", "allocations": 0, "locks": 0, "syscalls": 1}}
50
+ ],
51
+ "logs": [{"name": "stderr", "path": "/tmp/plugin-qualify-probe-0t8B87/child.stderr.log", "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "bytes": 0}]
52
+ }
@@ -0,0 +1,155 @@
1
+ {
2
+ "schema": "plugin-qualify.qualification-record/1",
3
+ "plugin": {
4
+ "format": "clap",
5
+ "id": "in.lsp-plug.trigger_midi_mono"
6
+ },
7
+ "binary": {
8
+ "path": "/usr/lib/clap/lsp-plugins.clap",
9
+ "sha256": "d5235d2f5f53eb3641dc9225ff5a912b5eae718cd0abbecd46350dba666f19a2",
10
+ "files": [
11
+ {
12
+ "path": "lsp-plugins.clap",
13
+ "sha256": "d5235d2f5f53eb3641dc9225ff5a912b5eae718cd0abbecd46350dba666f19a2"
14
+ }
15
+ ],
16
+ "layout": {
17
+ "scheme": "omx-layout/1",
18
+ "sha256": "5a2b073f777379e75dcd931ff9930352d12dcfab9e1fb599d3b1d45d62fe3e1d"
19
+ }
20
+ },
21
+ "verdicts": [
22
+ {
23
+ "host": "omx-clap-host",
24
+ "measured": true,
25
+ "rating": "unsuitable",
26
+ "qualified": false,
27
+ "hosting": "isolated",
28
+ "arm": "declaration",
29
+ "deciding": "hosting.rt-safety.violations-under-notes",
30
+ "reasons": [
31
+ {
32
+ "dimension": "rtSafety",
33
+ "rating": "suitable",
34
+ "code": "hosting.rt-safety.clean-under-sweep",
35
+ "params": {
36
+ "blocks": 512
37
+ }
38
+ },
39
+ {
40
+ "dimension": "rtSafety",
41
+ "rating": "unsuitable",
42
+ "code": "hosting.rt-safety.violations-under-notes",
43
+ "params": {
44
+ "allocations": 0,
45
+ "syscalls": 1,
46
+ "locks": 0
47
+ }
48
+ }
49
+ ]
50
+ }
51
+ ],
52
+ "tiers": {
53
+ "isolated": {
54
+ "host": "omx-clap-host",
55
+ "measured": true,
56
+ "qualified": false,
57
+ "rating": "unsuitable",
58
+ "deciding": "hosting.rt-safety.violations-under-notes"
59
+ },
60
+ "offeredOn": [],
61
+ "dimmed": "omx-clap-host: hosting.rt-safety.violations-under-notes"
62
+ },
63
+ "cost": [
64
+ {
65
+ "host": "omx-clap-host",
66
+ "rate": 48000,
67
+ "quantum": 128,
68
+ "reasons": [
69
+ {
70
+ "dimension": "cost",
71
+ "rating": "unknown",
72
+ "code": "hosting.cost.unmeasured-at-rate-and-quantum",
73
+ "params": {
74
+ "rate": 48000,
75
+ "quantum": 128
76
+ }
77
+ }
78
+ ]
79
+ }
80
+ ],
81
+ "measurements": {
82
+ "grid": [
83
+ {
84
+ "rate": 48000,
85
+ "quantum": 512,
86
+ "outcome": "probe.ok",
87
+ "latency": {
88
+ "declaredFrames": 0,
89
+ "measuredFrames": 0
90
+ },
91
+ "cost": {
92
+ "median": 47.9961,
93
+ "percentile": {
94
+ "p": 95,
95
+ "value": 57.2365
96
+ },
97
+ "blocks": 512,
98
+ "warmupBlocks": 64
99
+ },
100
+ "rtSafety": {
101
+ "interposer": true,
102
+ "swept": true,
103
+ "sweepApplicable": true,
104
+ "blocks": 512,
105
+ "allocations": 0,
106
+ "locks": 0,
107
+ "syscalls": 0,
108
+ "threadViolations": 0,
109
+ "processErrors": 0,
110
+ "events": []
111
+ },
112
+ "noteSweep": {
113
+ "applicable": true,
114
+ "dialect": "midi",
115
+ "allocations": 0,
116
+ "locks": 0,
117
+ "syscalls": 1
118
+ }
119
+ }
120
+ ]
121
+ },
122
+ "notMeasured": [
123
+ {
124
+ "dimension": "stability",
125
+ "code": "hosting.stability.unsoaked"
126
+ }
127
+ ],
128
+ "tool": {
129
+ "runner": "plugin-hostd-probe 0.13.1",
130
+ "child": "clap-qualify",
131
+ "interposer": "rt_interpose.so"
132
+ },
133
+ "measuredAt": "2026-10-28T21:01:52+00:00",
134
+ "probe": {
135
+ "file": "in.lsp-plug.trigger_midi_mono@unversioned-d5235d2f5f53.custom.probe.json",
136
+ "sha256": "17986ad33bc5087175fcce9512761cc28fb66d18beb01598ee2e72b5c5302fa6",
137
+ "bytes": 3978,
138
+ "protocol": {
139
+ "name": "plugin-hostd.probe-protocol",
140
+ "version": 1
141
+ },
142
+ "reason": "on-request",
143
+ "outcome": "probe.ok",
144
+ "complete": true,
145
+ "machineClass": {
146
+ "arch": "aarch64",
147
+ "family": "arm",
148
+ "coreBand": "3-4",
149
+ "clockBand": "1.5-2.5GHz",
150
+ "preemptRt": false,
151
+ "cstatesLimited": false
152
+ },
153
+ "meetsProtocol": false
154
+ }
155
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmixer/plugin-qualify",
3
- "version": "0.17.5",
3
+ "version": "0.18.1",
4
4
  "description": "Qualify audio plugins for real-time hosting: scan, offline latency and cost measurement, and a fail-closed rating.",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",
@@ -36,7 +36,7 @@ import {
36
36
  import type { QualifyVerdict } from "./qualify-cli.js";
37
37
  import { RATING_SEVERITY, worstRating, type SuitabilityRating } from "./rating.js";
38
38
  import { halfPopulatedRefusal, locateNativeTools } from "./tool-paths.js";
39
- import { readIntegrity, type IntegrityMeasurement, type VoicesHeld } from "./measurement/format.js";
39
+ import { readIntegrity, readMemoryFootprint, type IntegrityMeasurement, type MemoryFootprint, type VoicesHeld } from "./measurement/format.js";
40
40
  import { isVoiceBasis } from "./measurement/vocabulary.js";
41
41
  import { coreFraction } from "./cost-fields.js";
42
42
 
@@ -103,6 +103,8 @@ export interface ClapCostReading {
103
103
  /** The percentile `nsPerSampleP95` is taken at. */
104
104
  readonly percentile: number;
105
105
  readonly perRate: Readonly<Record<string, ClapCostAtRate>>;
106
+ /** What one instance held at each rate (`memory` on the prober's per-rate object), read apart from the timing. */
107
+ readonly memory?: Readonly<Record<string, MemoryFootprint>>;
106
108
  }
107
109
 
108
110
  /** One plugin's line from `clap-qualify`. */
@@ -285,9 +287,12 @@ function readCost(v: unknown): ClapCostReading {
285
287
  const perRateIn = v["perRate"];
286
288
  if (!isRecord(perRateIn)) return bad("cost has no perRate map");
287
289
  const perRate: Record<string, ClapCostAtRate> = {};
290
+ const memory: Record<string, MemoryFootprint> = {};
288
291
  for (const [rate, at] of Object.entries(perRateIn)) {
289
292
  if (!/^[1-9]\d*$/.test(rate)) return bad(`cost rate ${rate}`);
290
293
  perRate[rate] = readCostAtRate(at, `cost at ${rate}`, stimulus === "notes-held");
294
+ const footprint = isRecord(at) ? readMemoryFootprint(at["memory"]) : undefined;
295
+ if (footprint !== undefined) memory[rate] = footprint;
291
296
  }
292
297
  return {
293
298
  stimulus,
@@ -295,6 +300,7 @@ function readCost(v: unknown): ClapCostReading {
295
300
  warmupBlocks: count(v, "warmupBlocks", "cost"),
296
301
  percentile: figure(v, "percentile", "cost"),
297
302
  perRate,
303
+ ...(Object.keys(memory).length === 0 ? {} : { memory }),
298
304
  };
299
305
  }
300
306
 
@@ -338,6 +344,7 @@ export function qualifiedPluginOfClapLine(line: ClapQualifyLine, stability?: Plu
338
344
  clap,
339
345
  rtSafety: { ...rtSafety, noteSweep },
340
346
  cpuCost: { perRate: clapCostPerRate(line.cost) },
347
+ ...(line.cost?.memory === undefined ? {} : { memory: { perRate: line.cost.memory } }),
341
348
  ...(stability === undefined ? {} : { stability }),
342
349
  };
343
350
  }
@@ -0,0 +1,68 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * The hard limit of a process plugin-qualify spawns directly. Through plugin-hostd's runner a measured child has `memory.max`;
5
+ * a direct spawn has no runner, so it gets `RLIMIT_AS`, which needs no privilege. It is off for a sanitizer build, which
6
+ * reserves far more address space than it uses and would fail at start under any limit worth having.
7
+ *
8
+ * Hitting the limit is an outcome: the interposer ends the child with `MEMORY_BOUND_EXIT_STATUS` on the first refused
9
+ * allocation when `MEMORY_BOUND_EXIT_ENV` is set ({@link addressSpaceLimited} sets it), and {@link directOutcome} names it.
10
+ */
11
+ import { readFileSync } from "node:fs";
12
+ import { totalmem } from "node:os";
13
+
14
+ import { ADDRESS_SPACE_LIMIT_RAM_FRACTION, MEMORY_BOUND_EXIT_ENV, MEMORY_BOUND_EXIT_STATUS } from "./qualify-declarations.js";
15
+
16
+ /** The outcome of a direct spawn that hit its limit. */
17
+ export const MEMORY_LIMIT_EXCEEDED = "memory-limit-exceeded";
18
+ export const ADDRESS_SPACE_MECHANISM = "rlimit-as";
19
+
20
+ const SANITIZER_ENTRY = ["__asan_init", "__tsan_init", "__msan_init"];
21
+
22
+ /** Whether the program is built with a sanitizer that reserves address space: its own init symbol is in the binary. */
23
+ export function isSanitizerBuild(tool: string, read: (path: string) => Buffer = (p) => readFileSync(p)): boolean {
24
+ let bytes: Buffer;
25
+ try {
26
+ bytes = read(tool);
27
+ } catch {
28
+ return false;
29
+ }
30
+ return SANITIZER_ENTRY.some((s) => bytes.includes(s));
31
+ }
32
+
33
+ export interface DirectSpawn {
34
+ readonly command: string;
35
+ readonly args: readonly string[];
36
+ readonly env: Readonly<Record<string, string>>;
37
+ /** The limit it runs under, in bytes; absent for a sanitizer build. */
38
+ readonly limitBytes?: number;
39
+ }
40
+
41
+ /** The limit a direct spawn takes by default: `ADDRESS_SPACE_LIMIT_RAM_FRACTION` of the machine's RAM. */
42
+ export const defaultAddressSpaceLimit = (ramBytes: number = totalmem()): number => Math.floor(ramBytes * ADDRESS_SPACE_LIMIT_RAM_FRACTION);
43
+
44
+ /**
45
+ * How to spawn `tool` under the limit: the shell sets it and execs the program, since node has no way to set a child's
46
+ * rlimit. The program's own process stays the one spawned, so a signal or a timeout reaches it as before.
47
+ */
48
+ export function addressSpaceLimited(
49
+ tool: string,
50
+ args: readonly string[],
51
+ limitBytes: number = defaultAddressSpaceLimit(),
52
+ sanitizer: boolean = isSanitizerBuild(tool),
53
+ ): DirectSpawn {
54
+ if (sanitizer) return { command: tool, args, env: {} };
55
+ const kb = Math.ceil(limitBytes / 2 ** 10);
56
+ return {
57
+ command: "/bin/sh",
58
+ args: ["-c", `ulimit -v ${String(kb)} && exec "$@"`, "sh", tool, ...args],
59
+ env: { [MEMORY_BOUND_EXIT_ENV]: "1" },
60
+ limitBytes,
61
+ };
62
+ }
63
+
64
+ /** What the end of a direct spawn under a limit comes to: the outcome and the limit when it hit it, else nothing. */
65
+ export function directOutcome(status: number | null, spawn: DirectSpawn): { readonly outcome: string; readonly memoryLimit: { readonly bytes: number; readonly mechanism: string } } | undefined {
66
+ if (spawn.limitBytes === undefined || status !== MEMORY_BOUND_EXIT_STATUS) return undefined;
67
+ return { outcome: MEMORY_LIMIT_EXCEEDED, memoryLimit: { bytes: spawn.limitBytes, mechanism: ADDRESS_SPACE_MECHANISM } };
68
+ }
@@ -153,7 +153,7 @@ export function distributedUri(item: SyncItem, r: DistributedRecord): string {
153
153
  }
154
154
 
155
155
  /** What the sync's own judgment would make of the record's verdict for the host, as `outcomeOf` reads a run. */
156
- function outcomeOfVerdict(v: RecordVerdict): SyncOutcome {
156
+ export function outcomeOfVerdict(v: Pick<RecordVerdict, "measured" | "rating" | "deciding">): SyncOutcome {
157
157
  if (!v.measured) return v.deciding === "probe.crashed" ? "crashed" : v.deciding === "probe.timeout" ? "hung" : "unmeasured";
158
158
  if (v.rating === "suitable" || v.rating === "conditional") return "qualified";
159
159
  return v.rating === "unsuitable" ? "refused" : "unmeasured";
@@ -12,6 +12,7 @@ import { spawnSync } from "node:child_process";
12
12
  import { fileURLToPath } from "node:url";
13
13
 
14
14
  import type { HostProfile } from "./host-profiles.js";
15
+ import { ADMIT_STEP_MS_ENV } from "./qualify-declarations.js";
15
16
 
16
17
  /** The profile ids whose verdict is measured by a run. */
17
18
  export const ADMITTED_HOSTS = ["mod-host", "jalv"] as const;
@@ -24,11 +25,23 @@ export function admittedHostOf(profile: HostProfile): AdmittedHost | undefined {
24
25
 
25
26
  /**
26
27
  * plugin-hostd's LV2 adapter, omx-clap-host: an LV2 plugin is run under it once, as under mod-host, and the record
27
- * carries the outcome as its own step beside the verdicts (plugin-qualify#223), which it does not change: the
28
- * omx-clap-host verdict of an LV2 plugin stays the in-process measurement. It is what the removal of mod-host counts.
28
+ * carries the outcome as its own step (plugin-qualify#223). The run decides the omx-clap-host verdict of an LV2
29
+ * plugin, the isolated tier, as a mod-host run decides mod-host's; a CLAP plugin's stays the measurement.
29
30
  */
30
31
  export const ADAPTER_HOST = "omx-clap-host";
31
32
 
33
+ /** The profiles whose LV2 plugins run under that adapter: omx-clap-host, and the Pi boards it runs on (`host-profiles.ts`). */
34
+ export const ADAPTER_RUN_HOSTS: readonly string[] = [ADAPTER_HOST, "openmixer-pi-4gb", "openmixer-pi-8gb"];
35
+
36
+ /**
37
+ * Whether this run decides the profile's verdict for a plugin of this format. A mod-host or jalv run always does, an
38
+ * unmeasured one making the verdict unknown; the adapter's only when it measured, since the omx-clap-host verdict
39
+ * otherwise stands on the measurement.
40
+ */
41
+ export function runDecides(profile: HostProfile, format: string, admission: HostAdmission): boolean {
42
+ return admittedHostOf(profile) !== undefined || (ADAPTER_RUN_HOSTS.includes(profile.id) && format === "lv2" && admission.measured);
43
+ }
44
+
32
45
  /** What running the plugin under one host came to. */
33
46
  export interface HostAdmission {
34
47
  readonly host: AdmittedHost | typeof ADAPTER_HOST;
@@ -62,12 +75,16 @@ export interface ModHostStep {
62
75
  /** What `tools/mod-host-admit.mjs` printed. */
63
76
  export interface ModHostRun {
64
77
  readonly installed: boolean;
78
+ /** False when the host never took a connection (omx-clap-host with no JACK server): no plugin was loaded. */
79
+ readonly started?: boolean;
65
80
  readonly steps: readonly ModHostStep[];
66
81
  }
67
82
 
68
83
  /** The verdict of a run of a host that speaks mod-host's protocol: the first step that did not answer `resp 0` decides. */
69
84
  export function classifyProtocolRun(host: ProtocolHost, run: ModHostRun): HostAdmission {
70
85
  if (!run.installed) return notInstalled(host);
86
+ // the host never listened, so the plugin was never loaded: nothing was learned about it
87
+ if (run.started === false) return { host, measured: false, admitted: false, code: `${host}.not-started`, params: {} };
71
88
  for (const s of run.steps) {
72
89
  if (s.died !== undefined) {
73
90
  if (s.died.startsWith("SIG")) return refused(host, `signal-${s.died}`, { step: s.cmd });
@@ -131,7 +148,10 @@ const MOD_HOST_ADMIT = fileURLToPath(new URL("../tools/mod-host-admit.mjs", impo
131
148
  /** The runs against the installed hosts. */
132
149
  export const SPAWNED_ADMISSIONS: AdmissionRuns = {
133
150
  adapter(uri, env) {
134
- const r = spawnSync(process.execPath, [MOD_HOST_ADMIT, uri, ADAPTER_HOST], { encoding: "utf8", timeout: MOD_HOST_ADMISSION_MS, ...(env === undefined ? {} : { env: { ...env } }) });
151
+ // two commands at the limit the pass gives each, and the tool's own start and end: it kills the host itself first
152
+ const stepMs = Number(env?.[ADMIT_STEP_MS_ENV] ?? 0);
153
+ const timeout = stepMs > 0 ? 2 * stepMs + 15000 : MOD_HOST_ADMISSION_MS;
154
+ const r = spawnSync(process.execPath, [MOD_HOST_ADMIT, uri, ADAPTER_HOST], { encoding: "utf8", timeout, killSignal: "SIGKILL", ...(env === undefined ? {} : { env: { ...env } }) });
135
155
  if (r.error !== undefined || r.status !== 0) {
136
156
  throw new Error(`${ADAPTER_HOST} admission of ${uri} gave no answer (${r.error?.message ?? `exit ${String(r.status)}`}): ${(r.stderr ?? "").trim()}`);
137
157
  }
@@ -145,7 +165,10 @@ export const SPAWNED_ADMISSIONS: AdmissionRuns = {
145
165
  return JSON.parse(r.stdout) as ModHostRun;
146
166
  },
147
167
  jalv(uri, env) {
148
- const r = spawnSync("jalv", ["-n", "plugin-qualify", uri], { encoding: "utf8", timeout: JALV_HOSTED_MS, input: "", ...(env === undefined ? {} : { env: { ...env } }) });
168
+ // SIGKILL at the deadline: jalv hosting some plugins ignores SIGTERM, and one stuck in jack_client_open never reaches
169
+ // the loop that reads the flag TERM sets, so a TERM left spawnSync waiting with it (#187). Alive at the deadline is
170
+ // all the verdict asks, so nothing is lost by not letting it exit cleanly.
171
+ const r = spawnSync("jalv", ["-n", "plugin-qualify", uri], { encoding: "utf8", timeout: JALV_HOSTED_MS, killSignal: "SIGKILL", input: "", ...(env === undefined ? {} : { env: { ...env } }) });
149
172
  const code = (r.error as NodeJS.ErrnoException | undefined)?.code;
150
173
  if (code === "ENOENT") return { installed: false, stillRunning: false, status: null, signal: null, stderr: "" };
151
174
  return { installed: true, stillRunning: code === "ETIMEDOUT", status: r.status, signal: code === "ETIMEDOUT" ? null : r.signal, stderr: r.stderr ?? "" };
@@ -164,8 +164,24 @@ export const OMX_CLAP_HOST_PROFILE: HostProfile = {
164
164
  isolation: "shared-process",
165
165
  };
166
166
 
167
+ /**
168
+ * A Raspberry Pi with 4 GB, and with 8 GB: what runs a plugin there is omx-clap-host under plugin-hostd, so the same
169
+ * extensions, instruments and isolation, on the rates, quanta and memory of the board. Its memory budget is provisional
170
+ * ({@link HOST_BUDGETS}), so a plugin under it is `conditional` until the numbers are measured on a Pi, and the reason says
171
+ * so. Judged at every cell the board runs.
172
+ */
173
+ export const PI_4GB_PROFILE: HostProfile = { ...OMX_CLAP_HOST_PROFILE, id: "openmixer-pi-4gb", cost: HOST_BUDGETS["openmixer-pi-4gb"] };
174
+ export const PI_8GB_PROFILE: HostProfile = { ...OMX_CLAP_HOST_PROFILE, id: "openmixer-pi-8gb", cost: HOST_BUDGETS["openmixer-pi-8gb"] };
175
+
167
176
  /** Every profile the tool ships, by id. */
168
- export const HOST_PROFILES: readonly HostProfile[] = [OPENMIXER_CONSOLE_PROFILE, JALV_PROFILE, MOD_HOST_PROFILE, OMX_CLAP_HOST_PROFILE];
177
+ export const HOST_PROFILES: readonly HostProfile[] = [
178
+ OPENMIXER_CONSOLE_PROFILE,
179
+ JALV_PROFILE,
180
+ MOD_HOST_PROFILE,
181
+ OMX_CLAP_HOST_PROFILE,
182
+ PI_4GB_PROFILE,
183
+ PI_8GB_PROFILE,
184
+ ];
169
185
 
170
186
  /** A shipped profile by id, or `undefined`. */
171
187
  export function shippedHostProfile(id: string): HostProfile | undefined {
@@ -32,6 +32,10 @@ export const HOSTING_CODE = Object.freeze({
32
32
  "latencyParameterDependent": "hosting.latency.parameter-dependent",
33
33
  "latencyResolved": "hosting.latency.resolved",
34
34
  "latencyUnmeasured": "hosting.latency.unmeasured",
35
+ "memoryAboveCeiling": "hosting.memory.above-ceiling",
36
+ "memoryUnmeasured": "hosting.memory.unmeasured",
37
+ "memoryWithinCeiling": "hosting.memory.within-ceiling",
38
+ "memoryWithinProvisionalCeiling": "hosting.memory.within-provisional-ceiling",
35
39
  "outputNonFinite": "hosting.output.non-finite",
36
40
  "overrideIsolated": "hosting.override.curator-isolated",
37
41
  "patchBusy": "hosting.patch.busy",
@@ -45,6 +49,9 @@ export const HOSTING_CODE = Object.freeze({
45
49
  "noRealisation": "hosting.no-realisation",
46
50
  "realisedInProcess": "hosting.realised.in-process",
47
51
  "unjudged": "hosting.unjudged",
52
+ "resourceHintsOutOfRange": "hosting.resource-hints.out-of-range",
53
+ "resourceHintsUnknownHint": "hosting.resource-hints.unknown-hint",
54
+ "resourceHintsWrongUnit": "hosting.resource-hints.wrong-unit",
48
55
  "rtSafetyClean": "hosting.rt-safety.clean-under-sweep",
49
56
  "rtSafetyNotReproducible": "hosting.rt-safety.not-reproducible",
50
57
  "rtSafetyNotesClean": "hosting.rt-safety.clean-under-notes",
@@ -81,4 +88,7 @@ export const HOSTING_CODE = Object.freeze({
81
88
  "topologyWiderThanStrip": "hosting.topology.wider-than-strip",
82
89
  "workerNonconforming": "hosting.worker.nonconforming",
83
90
  "idleHoldRefused": "hosting.idle.hold-refused",
91
+ "windowNoDisplay": "hosting.window.no-display",
92
+ "windowUnsupported": "hosting.window.unsupported",
93
+ "windowRefused": "hosting.window.refused",
84
94
  } as const);