@primafuture/systemd-tasks 1.1.0 → 1.2.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/CHANGELOG.md +13 -0
- package/README.md +56 -1
- package/dist/finalizer.mjs +448 -0
- package/dist/finalizer.mjs.map +1 -0
- package/dist/observed.cjs.map +1 -1
- package/dist/observed.d.cts +1 -1
- package/dist/observed.d.ts +1 -1
- package/dist/observed.mjs.map +1 -1
- package/dist/runtime.cjs +533 -380
- package/dist/runtime.cjs.map +1 -1
- package/dist/runtime.d.cts +21 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.2.0 — 2026-10-10
|
|
4
|
+
|
|
5
|
+
- Add observed `finalizeAfterCompletion(reference, { afterRelease, timeoutMs })`:
|
|
6
|
+
an opt-in transient systemd-owned finalizer survives caller detach and death,
|
|
7
|
+
confirms payload/guardian cleanup, releases existing retention, and executes
|
|
8
|
+
one bounded native after-release command with finite stdin.
|
|
9
|
+
- Bind repeated registrations to the exact reference and callback, preserve
|
|
10
|
+
truthful registration outcomes, and collect the finalizer itself after exit.
|
|
11
|
+
- Cover post-payload reads with the same finalization deadline and honor native
|
|
12
|
+
callback stop grace through manager-owned cgroup termination.
|
|
13
|
+
- Keep default retention, existing intent/reference formats, manual lifecycle,
|
|
14
|
+
and output ownership unchanged.
|
|
15
|
+
|
|
3
16
|
## 1.1.0 — 2026-09-17 (local candidate)
|
|
4
17
|
|
|
5
18
|
- Add the `@primafuture/systemd-tasks/observed` entrypoint with immutable
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Run one-shot Linux processes owned by the **systemd user manager**, with literal
|
|
|
4
4
|
arguments, binary file-descriptor I/O, manager-owned timeouts, and recoverable
|
|
5
5
|
results after the Node client exits or crashes.
|
|
6
6
|
|
|
7
|
-
Version 1.
|
|
7
|
+
Version 1.2.0 requires Node 24.13.0+, Linux x64 with glibc 2.28+, systemd 257+,
|
|
8
8
|
and cgroup v2. The verified environment
|
|
9
9
|
is Node 24.13.0, systemd 257.9-0ubuntu2.4, Linux 6.17.0-14-generic, x86-64.
|
|
10
10
|
Other versions satisfying those minimums are not independently verified.
|
|
@@ -147,6 +147,60 @@ order, so it drains bytes already written before completing. This also closes a
|
|
|
147
147
|
guardian whose payload start was cancelled before exec. `release()` refuses until
|
|
148
148
|
both process services are clean, then removes retention.
|
|
149
149
|
|
|
150
|
+
### Automatic finalization of a background task
|
|
151
|
+
|
|
152
|
+
Observed clients can explicitly hand completion to the user manager:
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
const found = await client.lookup(intent);
|
|
156
|
+
if (found.status === 'found' && found.snapshot.identity === 'complete') {
|
|
157
|
+
await client.finalizeAfterCompletion(found.reference, {
|
|
158
|
+
timeoutMs: 30000,
|
|
159
|
+
afterRelease: {
|
|
160
|
+
executable: '/absolute/path/to/node',
|
|
161
|
+
argv: ['/absolute/path/to/application.mjs', '--finish-owned-record'],
|
|
162
|
+
cwd: '/absolute/application/directory',
|
|
163
|
+
env: {},
|
|
164
|
+
runtimeLimitMs: 10000,
|
|
165
|
+
stopGraceMs: 1000,
|
|
166
|
+
stdin: JSON.stringify({ generation: 'application-owned-generation' }),
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Register after persisting the exact reference and before advertising the background
|
|
173
|
+
task as ready. A separate transient finalizer survives observer detach, CLI exit,
|
|
174
|
+
and death of the original client. It does not stop the original task. It waits for
|
|
175
|
+
payload termination and confirmed payload/guardian cleanup, uses the existing
|
|
176
|
+
`release()` checks, and then executes the finite native command once, without a
|
|
177
|
+
shell, inherited environment, or output replay. The command runs in the finalizer's
|
|
178
|
+
own systemd control group; its output is discarded. The finalizer is collected
|
|
179
|
+
after it exits, including failure. Safe error metadata remains in the journal.
|
|
180
|
+
|
|
181
|
+
`timeoutMs` is one shared deadline for lookup, cleanup, release, and the
|
|
182
|
+
after-release command once the finalizer confirms payload termination. It does
|
|
183
|
+
not bound the task's active lifetime or restart for each phase. Callback runtime
|
|
184
|
+
is also limited by its non-null `runtimeLimitMs`, when supplied. On timeout the
|
|
185
|
+
finalizer exits; systemd stops its remaining command processes with `SIGTERM`,
|
|
186
|
+
allows `stopGraceMs`, and escalates to `SIGKILL` only if necessary. Stop grace is
|
|
187
|
+
a separate bounded cleanup interval, not additional execution time. The callback
|
|
188
|
+
may not be a resident service. A timed-out read or release never starts the callback.
|
|
189
|
+
|
|
190
|
+
The receipt says `accepted` or `already-registered`, not `completed`. An identical
|
|
191
|
+
registration while the finalizer exists does not create another callback. A
|
|
192
|
+
different callback for the same task is a conflict. An unknown registration outcome
|
|
193
|
+
must be reconciled before proceeding; never blindly submit a replacement task.
|
|
194
|
+
Failed cleanup does not execute the callback. Callback failure does not restore
|
|
195
|
+
released task results or automatically retry the callback.
|
|
196
|
+
|
|
197
|
+
This is deliberately opt-in and does not change the default retention policy.
|
|
198
|
+
After handing finalization off, do not rely on a late `wait()` retaining an exit
|
|
199
|
+
result: release can win that race. Applications should confirm their own cleanup
|
|
200
|
+
effect plus absence of the exact task rather than fabricate an exit status. The
|
|
201
|
+
callback must condition its own changes on its persisted generation. Recovery is
|
|
202
|
+
still required for a client death before registration and for manager restart.
|
|
203
|
+
|
|
150
204
|
## Lifecycle and recovery
|
|
151
205
|
|
|
152
206
|
| Operation | Contract |
|
|
@@ -160,6 +214,7 @@ both process services are clean, then removes retention.
|
|
|
160
214
|
| `stop(reference, options?)` | Request manager stop; `wait: true` additionally waits for cleanup. |
|
|
161
215
|
| `release(reference, options?)` | Refuse live tasks, then discard owned result retention. Never deletes logs. |
|
|
162
216
|
| `disconnect()` | Release local socket/listeners only; tasks keep running. Idempotent. |
|
|
217
|
+
| Observed `finalizeAfterCompletion(reference, options)` | Register exact manager-owned cleanup followed by one finite native command. Opt-in, not a completion receipt. |
|
|
163
218
|
|
|
164
219
|
Save the **intent before calling start**, then save an observed reference and final
|
|
165
220
|
result as they become available. Intents contain the command and environment,
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
// src/finalizer.ts
|
|
2
|
+
import * as childProcess from "child_process";
|
|
3
|
+
import * as performance from "perf_hooks";
|
|
4
|
+
import * as timers from "timers/promises";
|
|
5
|
+
|
|
6
|
+
// src/observed-finalization.ts
|
|
7
|
+
import * as crypto3 from "crypto";
|
|
8
|
+
import * as fs from "fs/promises";
|
|
9
|
+
import * as path2 from "path";
|
|
10
|
+
|
|
11
|
+
// src/errors.ts
|
|
12
|
+
import * as util from "util";
|
|
13
|
+
var TaskError = class extends Error {
|
|
14
|
+
/** Stable category used by applications instead of parsing human-readable messages. */
|
|
15
|
+
code;
|
|
16
|
+
/** Safe delivery and identity metadata; never contains a command specification. */
|
|
17
|
+
details;
|
|
18
|
+
/** Preserve the original cause for explicit access while keeping default output redacted. */
|
|
19
|
+
constructor(code, message, details = {}, cause) {
|
|
20
|
+
super(message, cause === void 0 ? void 0 : { cause });
|
|
21
|
+
this.name = "TaskError";
|
|
22
|
+
this.code = code;
|
|
23
|
+
this.details = Object.freeze({ ...details });
|
|
24
|
+
}
|
|
25
|
+
/** Serialization deliberately excludes raw causes, stack contents, and request payloads. */
|
|
26
|
+
toJSON() {
|
|
27
|
+
return { name: this.name, code: this.code, message: this.message, details: this.details };
|
|
28
|
+
}
|
|
29
|
+
/** Console and util.inspect must not expand a transport cause containing request data. */
|
|
30
|
+
[util.inspect.custom]() {
|
|
31
|
+
return `${this.name} [${this.code}]: ${this.message}`;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// src/observed-validation.ts
|
|
36
|
+
import * as crypto2 from "crypto";
|
|
37
|
+
|
|
38
|
+
// src/validation.ts
|
|
39
|
+
import * as crypto from "crypto";
|
|
40
|
+
import * as path from "path";
|
|
41
|
+
var maximumLocalTimeoutMs = 2147483647;
|
|
42
|
+
var maximumDurationMs = Number.MAX_SAFE_INTEGER;
|
|
43
|
+
function invalid(reason) {
|
|
44
|
+
throw new TaskError("INVALID_INPUT", "The task request is invalid.", { reason, mutationOutcome: "not-submitted" });
|
|
45
|
+
}
|
|
46
|
+
function record(value, reason) {
|
|
47
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) invalid(reason);
|
|
48
|
+
const prototype = Object.getPrototypeOf(value);
|
|
49
|
+
if (prototype !== Object.prototype && prototype !== null) invalid(reason);
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
function keys(value, allowed, reason) {
|
|
53
|
+
if (Object.keys(value).some(
|
|
54
|
+
/** Reject keys outside the explicit contract allowlist. */
|
|
55
|
+
(key) => !allowed.includes(key)
|
|
56
|
+
) || Object.getOwnPropertySymbols(value).length > 0) invalid(reason);
|
|
57
|
+
}
|
|
58
|
+
function string(value, reason) {
|
|
59
|
+
if (typeof value !== "string" || value.includes("\0")) invalid(reason);
|
|
60
|
+
for (const character of value) {
|
|
61
|
+
const code = character.codePointAt(0);
|
|
62
|
+
if (code >= 55296 && code <= 57343) invalid(reason);
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
function absolutePath(value, reason) {
|
|
67
|
+
const result = string(value, reason);
|
|
68
|
+
if (!path.posix.isAbsolute(result)) invalid(reason);
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
function duration(value, maximum, reason) {
|
|
72
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0 || value > maximum) invalid(reason);
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
function validateSpec(value) {
|
|
76
|
+
const source = record(value, "spec-object");
|
|
77
|
+
keys(source, ["executable", "argv", "cwd", "env", "runtimeLimitMs", "stopGraceMs", "systemdProperties"], "spec-keys");
|
|
78
|
+
const executable = absolutePath(source.executable, "executable-path");
|
|
79
|
+
const cwd = absolutePath(source.cwd, "cwd-path");
|
|
80
|
+
if (!Array.isArray(source.argv)) invalid("argv-array");
|
|
81
|
+
const argv = Object.freeze(source.argv.map(
|
|
82
|
+
/** Validate literal arguments without shell expansion or coercion. */
|
|
83
|
+
(value2) => string(value2, "argv-string")
|
|
84
|
+
));
|
|
85
|
+
const environment = record(source.env, "environment-object");
|
|
86
|
+
const env = /* @__PURE__ */ Object.create(null);
|
|
87
|
+
for (const key of Object.keys(environment).sort()) {
|
|
88
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) invalid("environment-name");
|
|
89
|
+
env[key] = string(environment[key], "environment-value");
|
|
90
|
+
}
|
|
91
|
+
const runtimeLimitMs = source.runtimeLimitMs === null ? null : duration(source.runtimeLimitMs, maximumDurationMs, "runtime-limit");
|
|
92
|
+
const stopGraceMs = duration(source.stopGraceMs, maximumDurationMs, "stop-grace");
|
|
93
|
+
const properties = [];
|
|
94
|
+
if (source.systemdProperties !== void 0) {
|
|
95
|
+
if (!Array.isArray(source.systemdProperties)) invalid("properties-array");
|
|
96
|
+
const names = /* @__PURE__ */ new Set();
|
|
97
|
+
for (const value2 of source.systemdProperties) {
|
|
98
|
+
const property = record(value2, "property-object");
|
|
99
|
+
keys(property, ["name", "signature", "value"], "property-keys");
|
|
100
|
+
if (property.name !== "CPUWeight" && property.name !== "IOWeight") invalid("reserved-or-unsupported-property");
|
|
101
|
+
if (property.signature !== "t") invalid("property-signature");
|
|
102
|
+
if (names.has(property.name)) invalid("duplicate-property");
|
|
103
|
+
names.add(property.name);
|
|
104
|
+
properties.push(Object.freeze({ name: property.name, signature: "t", value: duration(property.value, 1e4, "property-value") }));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
properties.sort(
|
|
108
|
+
/** Canonicalize resource ordering for a stable specification fingerprint. */
|
|
109
|
+
(left, right) => left.name.localeCompare(right.name)
|
|
110
|
+
);
|
|
111
|
+
return Object.freeze({ executable, argv, cwd, env: Object.freeze(env), runtimeLimitMs, stopGraceMs, systemdProperties: Object.freeze(properties) });
|
|
112
|
+
}
|
|
113
|
+
function uuid(value, reason) {
|
|
114
|
+
const result = string(value, reason);
|
|
115
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(result)) invalid(reason);
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
function digest(value, reason) {
|
|
119
|
+
const result = string(value, reason);
|
|
120
|
+
if (!/^[0-9a-f]{64}$/.test(result)) invalid(reason);
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
function validateInvocationId(value) {
|
|
124
|
+
if (value === null) return null;
|
|
125
|
+
const result = string(value, "invocation-id");
|
|
126
|
+
if (!/^[0-9a-f]{32}$/.test(result)) invalid("invocation-id");
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
function validateManagerIdentity(value) {
|
|
130
|
+
const source = record(value, "manager-identity");
|
|
131
|
+
keys(source, ["scope", "uid", "machineId", "bootId", "busId", "owner"], "manager-keys");
|
|
132
|
+
if (source.scope !== "user" || typeof source.uid !== "number" || !Number.isSafeInteger(source.uid) || source.uid < 0) invalid("manager-scope-or-uid");
|
|
133
|
+
const machineId = string(source.machineId, "machine-id");
|
|
134
|
+
const busId = string(source.busId, "bus-id");
|
|
135
|
+
const bootId = string(source.bootId, "boot-id");
|
|
136
|
+
const owner = string(source.owner, "manager-owner");
|
|
137
|
+
if (!/^[0-9a-f]{32}$/.test(machineId) || !/^[0-9a-f]{32}$/.test(busId)) invalid("manager-identifiers");
|
|
138
|
+
if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(bootId) || !/^:[0-9]+\.[0-9]+$/.test(owner)) invalid("manager-identifiers");
|
|
139
|
+
return Object.freeze({ scope: "user", uid: source.uid, machineId, bootId, busId, owner });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// src/observed-validation.ts
|
|
143
|
+
var maximumObservedInputBytes = 512 * 1024;
|
|
144
|
+
var maximumObservedInputBase64Characters = 4 * Math.ceil(maximumObservedInputBytes / 3);
|
|
145
|
+
var observedUnitPrefix = "pf-systemd-tasks-observed-";
|
|
146
|
+
var observedIdentityKeys = [
|
|
147
|
+
"kind",
|
|
148
|
+
"version",
|
|
149
|
+
"protocolVersion",
|
|
150
|
+
"id",
|
|
151
|
+
"unitName",
|
|
152
|
+
"retentionUnitName",
|
|
153
|
+
"guardianUnitName",
|
|
154
|
+
"managerScope",
|
|
155
|
+
"fingerprint"
|
|
156
|
+
];
|
|
157
|
+
function normalizeSpec(value) {
|
|
158
|
+
const source = record(value, "observed-spec-object");
|
|
159
|
+
keys(source, ["executable", "argv", "cwd", "env", "runtimeLimitMs", "stopGraceMs", "systemdProperties", "stdin"], "observed-spec-keys");
|
|
160
|
+
const spec = validateSpec({
|
|
161
|
+
executable: source.executable,
|
|
162
|
+
argv: source.argv,
|
|
163
|
+
cwd: source.cwd,
|
|
164
|
+
env: source.env,
|
|
165
|
+
runtimeLimitMs: source.runtimeLimitMs,
|
|
166
|
+
stopGraceMs: source.stopGraceMs,
|
|
167
|
+
...source.systemdProperties === void 0 ? {} : { systemdProperties: source.systemdProperties }
|
|
168
|
+
});
|
|
169
|
+
if (source.stdin === null) return Object.freeze({ spec, stdinBase64: null });
|
|
170
|
+
let bytes;
|
|
171
|
+
if (typeof source.stdin === "string") bytes = Buffer.from(source.stdin, "utf8");
|
|
172
|
+
else if (source.stdin instanceof Uint8Array) bytes = Buffer.from(source.stdin);
|
|
173
|
+
else return invalid("observed-stdin");
|
|
174
|
+
if (bytes.byteLength > maximumObservedInputBytes) invalid("observed-stdin-limit");
|
|
175
|
+
return Object.freeze({ spec, stdinBase64: bytes.toString("base64") });
|
|
176
|
+
}
|
|
177
|
+
function identity(source) {
|
|
178
|
+
if (source.kind !== "observed" || source.version !== 1 || source.protocolVersion !== 1 || source.managerScope !== "user") invalid("observed-identity-version-or-scope");
|
|
179
|
+
const id = uuid(source.id, "observed-identity-id");
|
|
180
|
+
if (source.unitName !== `${observedUnitPrefix}${id}.service` || source.retentionUnitName !== `${observedUnitPrefix}${id}-retention.service` || source.guardianUnitName !== `${observedUnitPrefix}${id}-guardian.service`) invalid("observed-identity-unit-name");
|
|
181
|
+
return Object.freeze({
|
|
182
|
+
kind: "observed",
|
|
183
|
+
version: 1,
|
|
184
|
+
protocolVersion: 1,
|
|
185
|
+
id,
|
|
186
|
+
unitName: source.unitName,
|
|
187
|
+
retentionUnitName: source.retentionUnitName,
|
|
188
|
+
guardianUnitName: source.guardianUnitName,
|
|
189
|
+
managerScope: "user",
|
|
190
|
+
fingerprint: digest(source.fingerprint, "observed-identity-fingerprint")
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
function validateObservedReference(value) {
|
|
194
|
+
const source = record(value, "observed-reference-object");
|
|
195
|
+
keys(source, [...observedIdentityKeys, "manager", "invocationId", "retentionInvocationId", "guardianInvocationId"], "observed-reference-keys");
|
|
196
|
+
return Object.freeze({
|
|
197
|
+
...identity(source),
|
|
198
|
+
manager: validateManagerIdentity(source.manager),
|
|
199
|
+
invocationId: validateInvocationId(source.invocationId),
|
|
200
|
+
retentionInvocationId: validateInvocationId(source.retentionInvocationId),
|
|
201
|
+
guardianInvocationId: validateInvocationId(source.guardianInvocationId)
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// src/observed-finalization.ts
|
|
206
|
+
var maximumBootstrapBytes = maximumObservedInputBytes * 2;
|
|
207
|
+
function requireCompleteReference(value) {
|
|
208
|
+
const reference = validateObservedReference(value);
|
|
209
|
+
if (!reference.invocationId || !reference.retentionInvocationId || !reference.guardianInvocationId) {
|
|
210
|
+
invalid("finalization-reference-incomplete");
|
|
211
|
+
}
|
|
212
|
+
return reference;
|
|
213
|
+
}
|
|
214
|
+
function prepare(reference, value) {
|
|
215
|
+
const source = record(value, "finalization-options");
|
|
216
|
+
keys(source, ["afterRelease", "timeoutMs"], "finalization-option-keys");
|
|
217
|
+
return Object.freeze({
|
|
218
|
+
reference: requireCompleteReference(reference),
|
|
219
|
+
timeoutMs: duration(source.timeoutMs, maximumLocalTimeoutMs, "finalization-timeout"),
|
|
220
|
+
afterRelease: normalizeSpec(source.afterRelease)
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
function parse(value) {
|
|
224
|
+
const source = record(value, "finalization-bootstrap");
|
|
225
|
+
keys(source, ["reference", "timeoutMs", "afterRelease"], "finalization-bootstrap-keys");
|
|
226
|
+
const afterRelease2 = record(source.afterRelease, "finalization-after-release");
|
|
227
|
+
keys(afterRelease2, ["spec", "stdinBase64"], "finalization-after-release-keys");
|
|
228
|
+
const spec = validateSpec(afterRelease2.spec);
|
|
229
|
+
let stdin = null;
|
|
230
|
+
if (afterRelease2.stdinBase64 !== null) {
|
|
231
|
+
const encoded = string(afterRelease2.stdinBase64, "finalization-stdin");
|
|
232
|
+
if (encoded.length > 4 * Math.ceil(maximumObservedInputBytes / 3)) {
|
|
233
|
+
invalid("finalization-stdin-limit");
|
|
234
|
+
}
|
|
235
|
+
stdin = Buffer.from(encoded, "base64");
|
|
236
|
+
if (stdin.toString("base64") !== encoded) {
|
|
237
|
+
invalid("finalization-stdin-base64");
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return prepare(requireCompleteReference(source.reference), {
|
|
241
|
+
timeoutMs: duration(source.timeoutMs, maximumLocalTimeoutMs, "finalization-timeout"),
|
|
242
|
+
afterRelease: { ...spec, stdin }
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// src/finalizer.ts
|
|
247
|
+
import * as runtime from "#runtime";
|
|
248
|
+
|
|
249
|
+
// src/waiting.ts
|
|
250
|
+
var WaitControl = class {
|
|
251
|
+
/** Internal cancellation combines the caller's abort with a local deadline. */
|
|
252
|
+
controller = new AbortController();
|
|
253
|
+
/** Local timer, absent for an explicitly unbounded wait. */
|
|
254
|
+
timer;
|
|
255
|
+
/** Caller signal whose listener must be removed at settlement. */
|
|
256
|
+
external;
|
|
257
|
+
/** Captured listener identity allows deterministic cleanup. */
|
|
258
|
+
onAbort;
|
|
259
|
+
/** A timeout is an observation deadline, never a manager escalation timeout. */
|
|
260
|
+
constructor(options) {
|
|
261
|
+
this.external = options.signal;
|
|
262
|
+
this.onAbort = /** Propagate caller cancellation into this local wait scope only. */
|
|
263
|
+
() => this.controller.abort(new TaskError("WAIT_ABORTED", "Local waiting was aborted."));
|
|
264
|
+
if (options.timeoutMs !== void 0) this.timer = setTimeout(
|
|
265
|
+
/** Expire local observation without changing systemd execution timers. */
|
|
266
|
+
() => this.controller.abort(new TaskError("WAIT_TIMEOUT", "The local waiting deadline expired.")),
|
|
267
|
+
options.timeoutMs
|
|
268
|
+
);
|
|
269
|
+
if (this.external?.aborted) this.onAbort();
|
|
270
|
+
else this.external?.addEventListener("abort", this.onAbort, { once: true });
|
|
271
|
+
}
|
|
272
|
+
/** Refuse an already-cancelled operation before it can submit a mutation. */
|
|
273
|
+
check() {
|
|
274
|
+
if (this.controller.signal.aborted) throw this.controller.signal.reason;
|
|
275
|
+
}
|
|
276
|
+
/** Stop this caller's observation while leaving a shared request or server mutation intact. */
|
|
277
|
+
async race(promise) {
|
|
278
|
+
if (this.controller.signal.aborted) {
|
|
279
|
+
void promise.catch(
|
|
280
|
+
/** Consume eventual settlement of work that outlives the cancelled caller. */
|
|
281
|
+
() => {
|
|
282
|
+
}
|
|
283
|
+
);
|
|
284
|
+
this.check();
|
|
285
|
+
}
|
|
286
|
+
const signal = this.controller.signal;
|
|
287
|
+
let abort;
|
|
288
|
+
const cancelled = new Promise(
|
|
289
|
+
/** Bridge local cancellation into the current asynchronous observation. */
|
|
290
|
+
(_resolve, reject) => {
|
|
291
|
+
abort = /** Reject with the original local cancellation category. */
|
|
292
|
+
() => reject(signal.reason);
|
|
293
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
294
|
+
}
|
|
295
|
+
);
|
|
296
|
+
try {
|
|
297
|
+
return await Promise.race([promise, cancelled]);
|
|
298
|
+
} finally {
|
|
299
|
+
if (abort) signal.removeEventListener("abort", abort);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/** Remove every local timer and listener without touching a running task. */
|
|
303
|
+
dispose() {
|
|
304
|
+
clearTimeout(this.timer);
|
|
305
|
+
this.external?.removeEventListener("abort", this.onAbort);
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// src/finalizer.ts
|
|
310
|
+
async function readBootstrap() {
|
|
311
|
+
const chunks = [];
|
|
312
|
+
let bytes = 0;
|
|
313
|
+
for await (const chunk of process.stdin) {
|
|
314
|
+
const buffer = Buffer.from(chunk);
|
|
315
|
+
bytes += buffer.byteLength;
|
|
316
|
+
if (bytes > maximumBootstrapBytes) {
|
|
317
|
+
throw new TaskError("INVALID_INPUT", "The finalizer input exceeds its bound.", {
|
|
318
|
+
reason: "finalization-bootstrap-limit"
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
chunks.push(buffer);
|
|
322
|
+
}
|
|
323
|
+
return parse(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
324
|
+
}
|
|
325
|
+
async function afterRelease(bootstrap, timeoutMs) {
|
|
326
|
+
const spec = bootstrap.afterRelease.spec;
|
|
327
|
+
const runtimeMs = Math.min(timeoutMs, spec.runtimeLimitMs ?? timeoutMs);
|
|
328
|
+
const control = new WaitControl({ timeoutMs: runtimeMs });
|
|
329
|
+
try {
|
|
330
|
+
control.check();
|
|
331
|
+
await control.race(new Promise(
|
|
332
|
+
/** Spawn neposkytuje shell, děděné env ani výstup do veřejné diagnostiky. */
|
|
333
|
+
(resolve2, reject) => {
|
|
334
|
+
const child = childProcess.spawn(spec.executable, [...spec.argv], {
|
|
335
|
+
cwd: spec.cwd,
|
|
336
|
+
env: { ...spec.env },
|
|
337
|
+
stdio: [bootstrap.afterRelease.stdinBase64 === null ? "ignore" : "pipe", "ignore", "ignore"],
|
|
338
|
+
shell: false
|
|
339
|
+
});
|
|
340
|
+
let failure;
|
|
341
|
+
child.once(
|
|
342
|
+
"error",
|
|
343
|
+
/** Uchová chybu exec; close uzavře i vlastněné descriptors. */
|
|
344
|
+
(error) => {
|
|
345
|
+
failure = error;
|
|
346
|
+
}
|
|
347
|
+
);
|
|
348
|
+
child.stdin?.once(
|
|
349
|
+
"error",
|
|
350
|
+
/** Nedokončený bootstrap se nesmí vydávat za úspěšný callback. */
|
|
351
|
+
(error) => {
|
|
352
|
+
failure = error;
|
|
353
|
+
}
|
|
354
|
+
);
|
|
355
|
+
child.once(
|
|
356
|
+
"close",
|
|
357
|
+
/** Výsledek dítěte není výsledek původního payloadu. */
|
|
358
|
+
(code, signal) => {
|
|
359
|
+
if (failure !== void 0 || code !== 0 || signal !== null) {
|
|
360
|
+
reject(new TaskError("RESULT_UNAVAILABLE", "The after-release command did not complete successfully.", {
|
|
361
|
+
reason: "after-release-failed",
|
|
362
|
+
taskId: bootstrap.reference.id
|
|
363
|
+
}, failure));
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
resolve2();
|
|
367
|
+
}
|
|
368
|
+
);
|
|
369
|
+
if (bootstrap.afterRelease.stdinBase64 !== null) {
|
|
370
|
+
child.stdin.end(Buffer.from(bootstrap.afterRelease.stdinBase64, "base64"));
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
));
|
|
374
|
+
} finally {
|
|
375
|
+
control.dispose();
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
async function run() {
|
|
379
|
+
const bootstrap = await readBootstrap();
|
|
380
|
+
const client = await runtime.connectObserved();
|
|
381
|
+
const budget = { deadline: null, control: null };
|
|
382
|
+
try {
|
|
383
|
+
const remaining = () => {
|
|
384
|
+
if (budget.deadline === null) {
|
|
385
|
+
budget.deadline = performance.performance.now() + bootstrap.timeoutMs;
|
|
386
|
+
budget.control = new WaitControl({ timeoutMs: bootstrap.timeoutMs });
|
|
387
|
+
}
|
|
388
|
+
budget.control.check();
|
|
389
|
+
const milliseconds = Math.floor(budget.deadline - performance.performance.now());
|
|
390
|
+
if (milliseconds <= 0) {
|
|
391
|
+
throw new TaskError("WAIT_TIMEOUT", "The finalization deadline expired.", {
|
|
392
|
+
reason: "finalization-timeout",
|
|
393
|
+
taskId: bootstrap.reference.id
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
return milliseconds;
|
|
397
|
+
};
|
|
398
|
+
for (; ; ) {
|
|
399
|
+
budget.control?.check();
|
|
400
|
+
const found = await (budget.control === null ? client.lookup(bootstrap.reference) : budget.control.race(client.lookup(bootstrap.reference)));
|
|
401
|
+
if (found.status === "not-found") {
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
if (found.status === "incomplete" || found.snapshot.payload.managerTerminal) {
|
|
405
|
+
remaining();
|
|
406
|
+
}
|
|
407
|
+
if (found.status === "found" && found.snapshot.phase === "completed") {
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
if (found.status === "incomplete" || found.snapshot.guardian.managerTerminal) {
|
|
411
|
+
const delayMs = Math.min(remaining(), 50);
|
|
412
|
+
await budget.control.race(timers.setTimeout(delayMs));
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
try {
|
|
416
|
+
const waitOptions = { timeoutMs: budget.deadline === null ? 1e3 : Math.min(remaining(), 1e3) };
|
|
417
|
+
const snapshot = await (budget.control === null ? client.wait(bootstrap.reference, waitOptions) : budget.control.race(client.wait(bootstrap.reference, waitOptions)));
|
|
418
|
+
if (snapshot.payload.managerTerminal) {
|
|
419
|
+
remaining();
|
|
420
|
+
}
|
|
421
|
+
if (snapshot.phase === "completed") {
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
} catch (error) {
|
|
425
|
+
if (!(error instanceof runtime.TaskError) || error.code !== "WAIT_TIMEOUT" && error.code !== "RESULT_UNAVAILABLE") {
|
|
426
|
+
throw error;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const releaseTimeoutMs = remaining();
|
|
431
|
+
await budget.control.race(client.release(bootstrap.reference, { timeoutMs: releaseTimeoutMs }));
|
|
432
|
+
const callbackTimeoutMs = remaining();
|
|
433
|
+
await budget.control.race(afterRelease(bootstrap, callbackTimeoutMs));
|
|
434
|
+
} finally {
|
|
435
|
+
budget.control?.dispose();
|
|
436
|
+
await client.disconnect();
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
void run().catch(
|
|
440
|
+
/** Journal dostane pouze vlastní redigovanou chybu, nikdy cizí payload. */
|
|
441
|
+
(error) => {
|
|
442
|
+
const reported = error instanceof TaskError || error instanceof runtime.TaskError ? error : new TaskError("RESULT_UNAVAILABLE", "The observed task finalizer failed.", { reason: "finalization-failed" });
|
|
443
|
+
process.stderr.write(`${JSON.stringify(reported.toJSON())}
|
|
444
|
+
`);
|
|
445
|
+
process.exit(1);
|
|
446
|
+
}
|
|
447
|
+
);
|
|
448
|
+
//# sourceMappingURL=finalizer.mjs.map
|