@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.
- package/lib/delivery-manifest.js +177 -0
- package/lib/frame-ledger.js +189 -0
- package/lib/index.js +4778 -0
- package/lib/paths.js +314 -0
- package/lib/project-store.js +528 -0
- package/lib/render-job-store.js +290 -0
- package/lib/render-journal.js +236 -0
- package/lib/render-reconciler.js +340 -0
- package/lib/revision-transaction.js +1064 -0
- package/lib/video-encoder.js +345 -0
- package/package.json +34 -0
|
@@ -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
|
+
}
|