@deepblend/dsh-blender-host 0.1.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,340 @@
1
+ /**
2
+ * Render Job Reconciler — what a freshly started Host can say about a render that
3
+ * was interrupted (SPEC §10.3).
4
+ *
5
+ * WHAT THE M3 PROBE MEASURED, AND WHY THIS FILE IS SHAPED LIKE THIS
6
+ * -----------------------------------------------------------------
7
+ * The probe (`deepblend/tools/m3-restart-probe.mjs`) SIGKILLed the process that
8
+ * had started a real 1080p/256-spp delivery render and then recovered in a third
9
+ * process. Three facts came out of it, and every one of them changed the design:
10
+ *
11
+ * 1. **The Blender child survives the Host.** It is spawned `detached`, so it
12
+ * leads its own process group and keeps rendering into the same directory.
13
+ * A recovery that reads the ledger BEFORE stopping it is describing a
14
+ * directory that is still being written to — so `stopOrphan` runs first, and
15
+ * the order is the point.
16
+ *
17
+ * 2. **The pid can only come from the child.** `ctx.subprocess.spawn` returns
18
+ * `{stdin, stdout, stderr, collected, done, terminate, waitForExit}` — it
19
+ * exposes no pid (measured against the installed runtime). So
20
+ * `bootstrap.py --proc <path>` writes `process.json` before it touches bpy,
21
+ * and that file is the only durable link between a job record and a process.
22
+ *
23
+ * 3. **macOS containment is a process group and nothing more.** On darwin
24
+ * `selectContainmentMode()` returns `fallback`, whose own warning says
25
+ * "descendants that escape the process group or direct-parent tree are not
26
+ * guaranteed to terminate". The group is what `terminate()` signals, and the
27
+ * probe confirmed `process.kill(-pid, SIGTERM)` does clear it
28
+ * (`gone: true`, `groupAlive: false`). This module signals the GROUP for the
29
+ * same reason, and re-verifies afterwards rather than trusting the signal.
30
+ *
31
+ * WHAT IT DELIBERATELY DOES NOT DO
32
+ * --------------------------------
33
+ * **It does not resume the render by itself.** SPEC §10.3 step 6 says resumable
34
+ * work continues from the missing frames, and that is delivered — by
35
+ * `blenderStudio.resumeRenderJob`, which the model (or the UI) calls. Auto-starting
36
+ * is refused for two concrete reasons: SPEC §16.4 fixes delivery-render
37
+ * concurrency at 1, and a Host can be started with several projects each holding
38
+ * an interrupted render, so auto-resume would launch several Blender processes at
39
+ * boot with no user present. Recovery leaves every job in `recovering`, with its
40
+ * ledger already computed, so the decision costs one call.
41
+ *
42
+ * SPEC §10.3 step 8 asks for a recovery event "to the UI and the Session". At Host
43
+ * startup there is no session yet — the reconciler runs before any agent exists —
44
+ * so an in-process event would have no listener by construction. What it writes
45
+ * instead is `recovery.json` in the job directory: a durable, auditable record of
46
+ * what was found, what was stopped and what remains, which outlives the process
47
+ * that discovered it.
48
+ *
49
+ * Owner: DeepBlend Studio — M3
50
+ * Plane: Host composition
51
+ */
52
+
53
+ import { execFileSync } from 'node:child_process'
54
+ import { existsSync, readFileSync } from 'node:fs'
55
+ import { join } from 'node:path'
56
+
57
+ import { writeJsonAtomic } from './paths.js'
58
+
59
+ /** Grace given to SIGTERM before SIGKILL, matching the provider's own ladder. */
60
+ export const ORPHAN_GRACE_MS = 10_000
61
+
62
+ /** How long to wait for the group to disappear after a signal. */
63
+ const POLL_INTERVAL_MS = 150
64
+
65
+ /**
66
+ * Is the process group led by `pid` still alive?
67
+ *
68
+ * Signalling `-pid` asks about the GROUP, which is the managed range this runtime
69
+ * actually owns on macOS. `ps` is consulted for identity, not liveness: a pid can
70
+ * be recycled, and killing a recycled pid would be the worst possible outcome of
71
+ * a recovery.
72
+ *
73
+ * @param {number} pid
74
+ * @returns {{alive: boolean, groupAlive: boolean, leaderAlive: boolean, command: string|null}}
75
+ */
76
+ export function checkProcessAlive(pid) {
77
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
78
+ return { alive: false, groupAlive: false, leaderAlive: false, command: null, invalid: true }
79
+ }
80
+ const probe = (target) => {
81
+ try {
82
+ process.kill(target, 0)
83
+ return true
84
+ } catch (error) {
85
+ // EPERM means it exists and belongs to someone else — still alive.
86
+ return error.code === 'EPERM'
87
+ }
88
+ }
89
+ const groupAlive = probe(-pid)
90
+ const leaderAlive = probe(pid)
91
+ return { alive: groupAlive || leaderAlive, groupAlive, leaderAlive, command: processCommand(pid) }
92
+ }
93
+
94
+ /**
95
+ * The full argv of a pid, or null.
96
+ *
97
+ * `ps -o args=` is used rather than `-o command=`: on macOS `command` is truncated
98
+ * for long argument lists, and the render's identity lives at the END of its argv
99
+ * (the `--request <jobDirectory>/request.json` pair).
100
+ *
101
+ * This uses `node:child_process` directly rather than `ctx.subprocess`. That is
102
+ * deliberate and is the one exception in this package: `ctx.subprocess` is the
103
+ * seam for the processes DeepBlend MANAGES, and a reconcile may need to inspect a
104
+ * pid whose owning service instance no longer exists. The call is a fixed argv,
105
+ * never a shell string, and its output is data (SPEC §15.2's argv rule is about
106
+ * how a process is started, and nothing is started here).
107
+ *
108
+ * @param {number} pid
109
+ * @returns {string|null}
110
+ */
111
+ function processCommand(pid) {
112
+ try {
113
+ const output = execFileSync('ps', ['-p', String(pid), '-o', 'args='], {
114
+ encoding: 'utf8',
115
+ timeout: 10_000,
116
+ }).trim()
117
+ return output.length > 0 ? output : null
118
+ } catch {
119
+ return null
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Does the recorded pid still belong to THIS job?
125
+ *
126
+ * A recycled pid that happens to be alive must never be signalled. The job
127
+ * directory appears twice in a render's argv — as `cwd` and inside `--request`,
128
+ * `--result` and `--plan` — so requiring it in the command line is a strong,
129
+ * cheap identity check.
130
+ *
131
+ * @param {{pid: number, jobDirectory: string, command: string|null}} input
132
+ * @returns {{matches: boolean, reason: string}}
133
+ */
134
+ export function identifyProcess(input) {
135
+ if (input.command === null || input.command.length === 0) {
136
+ return { matches: false, reason: 'the process has no readable command line' }
137
+ }
138
+ if (input.command.includes(input.jobDirectory)) {
139
+ return { matches: true, reason: 'the command line names this job directory' }
140
+ }
141
+ return {
142
+ matches: false,
143
+ reason: 'the live process is not this job\'s renderer (its command line does not name the job directory)',
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Terminate the orphan's process group: SIGTERM, wait, SIGKILL, wait, verify.
149
+ *
150
+ * The verification is the point. "I sent a signal" and "the process is gone" are
151
+ * different claims, and the M3 acceptance condition is the second one.
152
+ *
153
+ * @param {{pid: number, graceMs?: number}} input
154
+ * @returns {Promise<object>}
155
+ */
156
+ export async function stopProcessGroup(input) {
157
+ const pid = input.pid
158
+ const graceMs = input.graceMs ?? ORPHAN_GRACE_MS
159
+ const report = { pid, term: null, kill: null, gone: false }
160
+ const signal = (name) => {
161
+ try {
162
+ process.kill(-pid, name)
163
+ return 'signalled-group'
164
+ } catch (error) {
165
+ try {
166
+ process.kill(pid, name)
167
+ return `signalled-pid (group failed: ${error.code})`
168
+ } catch (inner) {
169
+ return `not-sent: ${inner.code}`
170
+ }
171
+ }
172
+ }
173
+
174
+ report.term = signal('SIGTERM')
175
+ if (await waitForGone(pid, graceMs)) {
176
+ report.gone = true
177
+ return report
178
+ }
179
+ report.kill = signal('SIGKILL')
180
+ report.gone = await waitForGone(pid, graceMs)
181
+ return report
182
+ }
183
+
184
+ async function waitForGone(pid, timeoutMs) {
185
+ const deadline = Date.now() + timeoutMs
186
+ while (Date.now() < deadline) {
187
+ if (checkProcessAlive(pid).alive === false) return true
188
+ await new Promise(resolveWait => setTimeout(resolveWait, POLL_INTERVAL_MS))
189
+ }
190
+ return checkProcessAlive(pid).alive === false
191
+ }
192
+
193
+ /**
194
+ * Reconcile one interrupted render job.
195
+ *
196
+ * @param {object} input
197
+ * @param {import('./render-job-store.js').RenderJobStore} input.store
198
+ * @param {import('./frame-ledger.js').readFrameLedger} input.readFrameLedger
199
+ * @param {string} input.projectId
200
+ * @param {string} input.jobId
201
+ * @param {object|null} input.record
202
+ * @param {(record: object) => object} input.write - persists the updated record.
203
+ * @param {number} [input.orphanGraceMs] - how long to wait for a signalled orphan to die. Defaults to
204
+ * `ORPHAN_GRACE_MS` (10 s), which is what a real recovery uses; a test that has to reach the
205
+ * "survived" branch otherwise spends twenty seconds proving a refusal.
206
+ * @returns {Promise<object>} the finding, which is also what `recovery.json` holds.
207
+ */
208
+ export async function reconcileRenderJob(input) {
209
+ const { store, projectId, jobId, record } = input
210
+ const jobDirectory = store.jobDirectory(projectId, jobId)
211
+ const finding = {
212
+ schemaVersion: 'deepblend.render-recovery/v1',
213
+ jobId,
214
+ projectId,
215
+ reconciledAt: new Date().toISOString(),
216
+ previousStatus: record?.status ?? null,
217
+ status: null,
218
+ process: null,
219
+ ledger: null,
220
+ notes: [],
221
+ }
222
+
223
+ if (record === null) {
224
+ // A record that cannot be parsed is REPORTED, never repaired in place: the
225
+ // file may still be the only evidence of what the job was doing, and
226
+ // overwriting it would destroy exactly the thing a human needs to look at.
227
+ finding.status = 'unreadable'
228
+ finding.notes.push(
229
+ `${store.recordPath(projectId, jobId)} is unreadable or not a render job record; ` +
230
+ 'it is left untouched so the evidence survives, and the job is reported on every startup until it is resolved',
231
+ )
232
+ writeRecovery(jobDirectory, finding)
233
+ return finding
234
+ }
235
+
236
+ const expected = store.expectedFrames(record)
237
+ const framesDirectory = store.framesDirectory(projectId, jobId)
238
+
239
+ // (1) Stop the orphan BEFORE reading the ledger. A live renderer is writing
240
+ // into the very directory the ledger describes, so the other order produces an
241
+ // answer that is already stale by the time it is returned.
242
+ const identityPath = join(jobDirectory, 'process.json')
243
+ const identity = existsSync(identityPath) ? readJsonSafe(identityPath) : null
244
+ const recordedPid = record.pid ?? identity?.pid ?? null
245
+ if (recordedPid !== null) {
246
+ const alive = checkProcessAlive(recordedPid)
247
+ const identityVerdict = alive.alive
248
+ ? identifyProcess({ pid: recordedPid, jobDirectory, command: alive.command })
249
+ : { matches: false, reason: 'the process is gone' }
250
+ finding.process = { pid: recordedPid, alive: alive.alive, identity: identityVerdict }
251
+ if (alive.alive && identityVerdict.matches) {
252
+ const stopped = await stopProcessGroup({ pid: recordedPid, graceMs: input.orphanGraceMs })
253
+ finding.process.stopped = stopped
254
+ finding.notes.push(
255
+ `an orphaned renderer (pid ${recordedPid}) survived the previous Host and was stopped: ` +
256
+ `${stopped.term}${stopped.kill !== null ? `, then ${stopped.kill}` : ''}, gone=${stopped.gone}`,
257
+ )
258
+ if (stopped.gone !== true) {
259
+ // Refusing to resume is the only safe answer: a resumer would race the
260
+ // orphan for the same frame files, and a frame written by two processes
261
+ // is a frame nobody can account for.
262
+ finding.status = 'orphan-survived'
263
+ finding.notes.push(
264
+ 'the orphan could not be stopped, so the job is NOT resumable: a second renderer writing the same ' +
265
+ 'frames would produce files neither process can vouch for',
266
+ )
267
+ writeRecovery(jobDirectory, finding)
268
+ return finding
269
+ }
270
+ } else if (alive.alive) {
271
+ finding.notes.push(
272
+ `pid ${recordedPid} is alive but is not this job's renderer (${identityVerdict.reason}); it was left alone`,
273
+ )
274
+ } else {
275
+ finding.notes.push(`the renderer recorded for this job (pid ${recordedPid}) is no longer running`)
276
+ }
277
+ } else {
278
+ finding.notes.push('the job recorded no pid, so there was no process to check')
279
+ }
280
+
281
+ // (2) The frames are the authority.
282
+ const ledger = input.readFrameLedger({
283
+ framesDirectory,
284
+ expected,
285
+ expectedSize: {
286
+ width: record.renderConfig?.resolution?.[0],
287
+ height: record.renderConfig?.resolution?.[1],
288
+ prefix: record.filePrefix,
289
+ padding: record.filePadding,
290
+ },
291
+ })
292
+ finding.ledger = {
293
+ expected: expected.length,
294
+ present: ledger.presentCount,
295
+ corrupt: ledger.corruptCount,
296
+ missing: ledger.missingCount,
297
+ toRender: ledger.toRender,
298
+ toRenderCount: ledger.toRenderCount,
299
+ corruptFrames: ledger.corrupt.map(entry => ({ frame: entry.frame, reason: entry.reason, bytes: entry.bytes })),
300
+ }
301
+
302
+ // (3) Decide. A job whose frames are all present is still not finished — it
303
+ // still owes an encoded video and a manifest — so "complete frames" is NOT
304
+ // `completed`. Marking it completed here is exactly how a delivery silently
305
+ // ships without a video.
306
+ const next = {
307
+ ...record,
308
+ status: 'recovering',
309
+ completedFrames: ledger.present.map(entry => entry.frame),
310
+ missingFrames: ledger.toRender,
311
+ recoveredAt: finding.reconciledAt,
312
+ errorCode: null,
313
+ message: ledger.toRenderCount === 0
314
+ ? 'all frames are present; the job still owes its encoded video and delivery manifest'
315
+ : `${ledger.toRenderCount} frame(s) still owed: ${ledger.corruptCount} incomplete, ${ledger.missingCount} absent`,
316
+ }
317
+ finding.status = 'recovering'
318
+ finding.resumable = true
319
+ const written = input.write(next)
320
+ finding.record = { status: written.status, completedFrames: written.completedFrames.length }
321
+ writeRecovery(jobDirectory, finding)
322
+ return finding
323
+ }
324
+
325
+ function writeRecovery(jobDirectory, finding) {
326
+ try {
327
+ writeJsonAtomic(join(jobDirectory, 'recovery.json'), finding)
328
+ } catch {
329
+ // A recovery report that cannot be written must not abort a recovery: the
330
+ // record itself is the authority, and this is the audit trail beside it.
331
+ }
332
+ }
333
+
334
+ function readJsonSafe(path) {
335
+ try {
336
+ return JSON.parse(readFileSync(path, 'utf8'))
337
+ } catch {
338
+ return null
339
+ }
340
+ }