@primafuture/systemd-tasks 1.0.0 → 1.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/CHANGELOG.md +27 -0
- package/README.md +86 -8
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/dist/guardian.mjs +212 -0
- package/dist/guardian.mjs.map +1 -0
- package/dist/index.cjs +9 -13287
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -316
- package/dist/index.d.ts +1 -316
- package/dist/index.mjs +2 -13304
- package/dist/index.mjs.map +1 -1
- package/dist/observed.cjs +35 -0
- package/dist/observed.cjs.map +1 -0
- package/dist/observed.d.cts +1 -0
- package/dist/observed.d.ts +1 -0
- package/dist/observed.mjs +8 -0
- package/dist/observed.mjs.map +1 -0
- package/dist/runtime.cjs +14229 -0
- package/dist/runtime.cjs.map +1 -0
- package/dist/runtime.d.cts +467 -0
- package/docs/implementation-decisions.md +137 -0
- package/package.json +27 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.1.0 — 2026-09-17 (local candidate)
|
|
4
|
+
|
|
5
|
+
- Add the `@primafuture/systemd-tasks/observed` entrypoint with immutable
|
|
6
|
+
JSON-safe intents, finite manager-owned stdin, live binary stdout/stderr, and
|
|
7
|
+
separate payload/guardian lifecycle evidence.
|
|
8
|
+
- Create payload, retention, and `Type=notify` guardian services in one atomic
|
|
9
|
+
transaction. Both process services require successful retention initialization;
|
|
10
|
+
payload stop propagates to a drain-aware guardian even when exec was cancelled.
|
|
11
|
+
Fail closed when the guardian dies, while allowing it to drain output after
|
|
12
|
+
payload exit, stop, timeout, client detach, or client death.
|
|
13
|
+
- Add a versioned bounded guardian protocol, sequential asynchronous callback
|
|
14
|
+
backpressure, explicit local detach, and stable `OBSERVATION_FAILED` errors.
|
|
15
|
+
Reconnect, replay, and stream adoption remain deliberately unsupported.
|
|
16
|
+
- Generalize the existing D-Bus adapter to an ordered auxiliary-unit topology and
|
|
17
|
+
share transport ownership, mutation serialization, notification routing,
|
|
18
|
+
cleanup, diagnostics, and `TaskError` identity across root/observed APIs and
|
|
19
|
+
mixed ESM/CommonJS loading.
|
|
20
|
+
- Upgrade the external native transport to the exact published
|
|
21
|
+
`@primafuture/socket-fdx@1.1.0` release and use its owned `socketPair()` and
|
|
22
|
+
`adoptSocket()` primitives.
|
|
23
|
+
- Extend deterministic, real-systemd, installed ESM/CommonJS, and TypeScript
|
|
24
|
+
consumer tests, including the real 512 KiB stdin boundary, partial socket
|
|
25
|
+
allocation rollback, pre-readiness detach, cross-client pre-exec stop, final
|
|
26
|
+
output under slow-callback backpressure during explicit stop, 32 MiB per-channel draining, and guardian
|
|
27
|
+
fail-closed behavior. Detach also settles independently of an already-pending
|
|
28
|
+
application callback. No npm publish was performed.
|
|
29
|
+
|
|
3
30
|
## 1.0.0 — 2026-09-07
|
|
4
31
|
|
|
5
32
|
- Add explicit preparation, single-attempt submission, lookup, inspection, waiting,
|
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.1.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.
|
|
@@ -15,11 +15,12 @@ Other versions satisfying those minimums are not independently verified.
|
|
|
15
15
|
npm install @primafuture/systemd-tasks
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
The
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
The native dependency `@primafuture/socket-fdx@1.1.0` is installed under the npm
|
|
19
|
+
alias `usocket` and supplies both the D-Bus adapter and explicitly owned Unix
|
|
20
|
+
socket-pair primitives. It is pinned to the exact published release and ships a
|
|
21
|
+
prebuilt native binary, so ordinary installation needs no compiler, Python, install
|
|
22
|
+
scripts, or consumer override. Musl Linux and other architectures remain unsupported.
|
|
23
|
+
The JavaScript D-Bus stack
|
|
23
24
|
(`dbus-next@0.10.2` and its dependencies) is bundled; licenses are recorded in
|
|
24
25
|
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
|
25
26
|
|
|
@@ -29,6 +30,9 @@ that the retention executable resolves to a regular executable file before openi
|
|
|
29
30
|
the bus, then verifies the manager environment. It fails explicitly; the library does not provision a manager,
|
|
30
31
|
enable linger, request elevated privileges, or fall back to child_process.
|
|
31
32
|
Importing either the ESM or CommonJS entrypoint does not connect or start a process.
|
|
33
|
+
Observed tasks additionally require `/usr/bin/systemd-notify`, `/usr/bin/systemctl`,
|
|
34
|
+
and the packaged guardian bundle; `connectObserved()` verifies them before opening
|
|
35
|
+
the bus.
|
|
32
36
|
|
|
33
37
|
## Basic execution
|
|
34
38
|
|
|
@@ -70,6 +74,79 @@ environment that are absent from this map, using a snapshot immediately before
|
|
|
70
74
|
submission. Systemd may add execution metadata such as `INVOCATION_ID`. Concurrent
|
|
71
75
|
external changes to manager configuration are outside this guarantee.
|
|
72
76
|
|
|
77
|
+
## Observed execution
|
|
78
|
+
|
|
79
|
+
The separate `@primafuture/systemd-tasks/observed` entrypoint adds live binary
|
|
80
|
+
stdout/stderr delivery without wrapping the payload process:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import * as observed from '@primafuture/systemd-tasks/observed';
|
|
84
|
+
|
|
85
|
+
const intent = observed.prepareObserved({
|
|
86
|
+
executable: '/usr/bin/sort',
|
|
87
|
+
argv: [],
|
|
88
|
+
cwd: '/tmp',
|
|
89
|
+
env: {},
|
|
90
|
+
runtimeLimitMs: 5000,
|
|
91
|
+
stopGraceMs: 500,
|
|
92
|
+
stdin: 'second\nfirst\n',
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const client = await observed.connectObserved();
|
|
96
|
+
try {
|
|
97
|
+
const started = await client.startObserved(intent, {
|
|
98
|
+
async onOutput(event) {
|
|
99
|
+
// Awaiting here applies bounded backpressure to both output channels.
|
|
100
|
+
await consume(event.stream, event.data);
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
await started.observation.completion;
|
|
104
|
+
const completed = await client.wait(intent, { timeoutMs: 10000 });
|
|
105
|
+
await client.release(completed.reference);
|
|
106
|
+
} finally {
|
|
107
|
+
await client.disconnect();
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`stdin` is mandatory. A string is snapshotted as UTF-8, a `Uint8Array` is copied,
|
|
112
|
+
an empty value remains an explicit empty data input, and `null` selects `/dev/null`.
|
|
113
|
+
The fixed limit is 512 KiB, chosen to remain below systemd's manager-state line
|
|
114
|
+
serialization boundary after base64 expansion. The canonical base64 snapshot is
|
|
115
|
+
part of the serialized intent and its fingerprint. Systemd owns `StandardInputData` after submission and
|
|
116
|
+
may retain those bytes in manager state until release, so treat the intent and
|
|
117
|
+
manager access as sensitive when stdin contains secrets.
|
|
118
|
+
|
|
119
|
+
Each observed task consists of payload, retention, and `Type=notify` guardian
|
|
120
|
+
services created by one `StartTransientUnit` transaction. The payload is ordered
|
|
121
|
+
after and `BindsTo` the guardian; both process services require successful
|
|
122
|
+
retention initialization. `startObserved()` returns only after manager
|
|
123
|
+
acceptance and guardian readiness, with a fixed five-second readiness budget. A
|
|
124
|
+
failure after manager acceptance reports `mutationOutcome: 'accepted'`; it is not
|
|
125
|
+
permission to retry.
|
|
126
|
+
|
|
127
|
+
Output callbacks are sequential and their returned promises provide real bounded
|
|
128
|
+
backpressure. Callback failure rejects `observation.completion` with
|
|
129
|
+
`OBSERVATION_FAILED` and detaches locally; it does not stop the payload. `detach()`
|
|
130
|
+
and `disconnect()` likewise close only the local observer. The guardian continues
|
|
131
|
+
to drain and discard both channels so the payload does not receive `EPIPE` merely
|
|
132
|
+
because the client left. There is deliberately no reconnect, replay, or adoption
|
|
133
|
+
of another client's live stream. Use ordinary durable file I/O instead when output
|
|
134
|
+
must survive observation loss.
|
|
135
|
+
|
|
136
|
+
An already-invoked callback is application code and cannot be cancelled. Explicit
|
|
137
|
+
detach closes the socket and settles `completion` as `detached` without waiting for
|
|
138
|
+
that callback promise; no later callback is invoked, and a later settlement of the
|
|
139
|
+
detached callback cannot change the observation outcome or block `disconnect()`.
|
|
140
|
+
|
|
141
|
+
The guardian emits a private versioned binary protocol with separate stdout and
|
|
142
|
+
stderr frames. Payload bytes and callback causes are never included in diagnostics.
|
|
143
|
+
`wait()` reports payload and guardian results independently and reaches `completed`
|
|
144
|
+
only after both services are terminal with confirmed empty cgroups. `stop()` targets
|
|
145
|
+
the payload; systemd propagates that stop to the guardian in reverse dependency
|
|
146
|
+
order, so it drains bytes already written before completing. This also closes a
|
|
147
|
+
guardian whose payload start was cancelled before exec. `release()` refuses until
|
|
148
|
+
both process services are clean, then removes retention.
|
|
149
|
+
|
|
73
150
|
## Lifecycle and recovery
|
|
74
151
|
|
|
75
152
|
| Operation | Contract |
|
|
@@ -206,7 +283,7 @@ service manipulation can invalidate evidence. The library does not enable linger
|
|
|
206
283
|
recreate missing tasks, implement a daemon, or promise survival when the manager
|
|
207
284
|
itself stops. Connection failure is distinct from missing units.
|
|
208
285
|
|
|
209
|
-
The only advanced properties allowed in 1.
|
|
286
|
+
The only advanced properties allowed in 1.x are `CPUWeight` and `IOWeight`, each
|
|
210
287
|
with signature `'t'` and an integer value from 1 to 10000. Duplicates, unknown
|
|
211
288
|
properties, and lifecycle overrides are rejected before mutation. Kernel/controller
|
|
212
289
|
configuration determines the practical scheduling effect of those weights.
|
|
@@ -228,7 +305,8 @@ npm run check
|
|
|
228
305
|
`check` runs strict typechecking, unit tests, real systemd integration tests, dual
|
|
229
306
|
ESM/CJS build, and tarball runtime/type smoke tests. `prepublishOnly` invokes the
|
|
230
307
|
same chain. Integration tests fail explicitly when prerequisites are missing.
|
|
231
|
-
Tests create unique identities and clean only their recorded task/retention pairs
|
|
308
|
+
Tests create unique identities and clean only their recorded task/retention pairs,
|
|
309
|
+
plus the exact guardian identity for observed tests.
|
|
232
310
|
They do not restart the user manager or change host configuration.
|
|
233
311
|
|
|
234
312
|
Runnable repository examples after `npm run build`:
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -25,7 +25,7 @@ This file is generated by `scripts/licenses.ts` from the distributed source map.
|
|
|
25
25
|
|
|
26
26
|
## External native dependency
|
|
27
27
|
|
|
28
|
-
`@primafuture/socket-fdx` 1.
|
|
28
|
+
`@primafuture/socket-fdx` 1.1.0 (ISC) is installed separately under the npm alias `usocket`. It ships its native binary, license, and third-party notices, has no runtime dependencies, and is not embedded in this JavaScript bundle. The direct pinned alias applies in consumer installations without root overrides.
|
|
29
29
|
|
|
30
30
|
## @nornagon/put 0.0.8
|
|
31
31
|
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// src/guardian.ts
|
|
2
|
+
import * as childProcess from "child_process";
|
|
3
|
+
import * as fs from "fs";
|
|
4
|
+
import * as net from "net";
|
|
5
|
+
|
|
6
|
+
// src/observed-framing.ts
|
|
7
|
+
var magic = Buffer.from("SDTO", "ascii");
|
|
8
|
+
var version = 1;
|
|
9
|
+
var headerBytes = 10;
|
|
10
|
+
var maximumPayloadBytes = 64 * 1024;
|
|
11
|
+
var kind = Object.freeze({ ready: 1, stdout: 2, stderr: 3, complete: 4 });
|
|
12
|
+
function encode(frameKind, payload = new Uint8Array()) {
|
|
13
|
+
if (!Object.values(kind).includes(frameKind) || payload.byteLength > maximumPayloadBytes) throw new Error("Invalid observed guardian frame.");
|
|
14
|
+
const frame = Buffer.allocUnsafe(headerBytes + payload.byteLength);
|
|
15
|
+
magic.copy(frame, 0);
|
|
16
|
+
frame[4] = version;
|
|
17
|
+
frame[5] = frameKind;
|
|
18
|
+
frame.writeUInt32BE(payload.byteLength, 6);
|
|
19
|
+
frame.set(payload, headerBytes);
|
|
20
|
+
return frame;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// src/guardian.ts
|
|
24
|
+
function fail(message, cause) {
|
|
25
|
+
throw new Error(`Observed task guardian failed: ${message}`, cause === void 0 ? void 0 : { cause });
|
|
26
|
+
}
|
|
27
|
+
function validateDescriptors() {
|
|
28
|
+
if (process.env.LISTEN_PID !== String(process.pid)) fail("LISTEN_PID does not identify this process.");
|
|
29
|
+
if (process.env.LISTEN_FDS !== "3") fail("LISTEN_FDS is not exactly three.");
|
|
30
|
+
if (process.env.LISTEN_FDNAMES !== "task-stdout:task-stderr:observer") fail("LISTEN_FDNAMES has an unexpected order.");
|
|
31
|
+
for (const descriptor of [3, 4, 5]) if (!fs.fstatSync(descriptor).isSocket()) fail(`descriptor ${descriptor} is not a socket.`);
|
|
32
|
+
delete process.env.LISTEN_PID;
|
|
33
|
+
delete process.env.LISTEN_FDS;
|
|
34
|
+
delete process.env.LISTEN_FDNAMES;
|
|
35
|
+
}
|
|
36
|
+
function notifyReady() {
|
|
37
|
+
const result = childProcess.spawnSync("/usr/bin/systemd-notify", ["--ready", "--pid=parent"], {
|
|
38
|
+
env: process.env,
|
|
39
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
40
|
+
encoding: "utf8",
|
|
41
|
+
timeout: 5e3
|
|
42
|
+
});
|
|
43
|
+
if (result.error) fail("systemd-notify could not execute.", result.error);
|
|
44
|
+
if (result.status !== 0) fail(`systemd-notify exited with status ${String(result.status)}.`);
|
|
45
|
+
}
|
|
46
|
+
function payloadWasInvoked(unitName) {
|
|
47
|
+
if (process.getuid === void 0) return null;
|
|
48
|
+
const runtimeDirectory = `/run/user/${String(process.getuid())}`;
|
|
49
|
+
const result = childProcess.spawnSync("/usr/bin/systemctl", ["--user", "show", unitName, "--property=InvocationID", "--value"], {
|
|
50
|
+
env: { XDG_RUNTIME_DIR: runtimeDirectory, DBUS_SESSION_BUS_ADDRESS: `unix:path=${runtimeDirectory}/bus` },
|
|
51
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
52
|
+
encoding: "utf8",
|
|
53
|
+
timeout: 5e3,
|
|
54
|
+
maxBuffer: 4096
|
|
55
|
+
});
|
|
56
|
+
if (result.error || result.status !== 0) return null;
|
|
57
|
+
const invocationId = result.stdout.trim();
|
|
58
|
+
if (invocationId === "") return false;
|
|
59
|
+
return /^[0-9a-f]{32}$/.test(invocationId) ? true : null;
|
|
60
|
+
}
|
|
61
|
+
async function writeReady(observer) {
|
|
62
|
+
return await new Promise(
|
|
63
|
+
/** Chyba client-facing socketu znamená detach, nikoliv selhání guardian infrastruktury. */
|
|
64
|
+
(resolve) => {
|
|
65
|
+
try {
|
|
66
|
+
observer.write(encode(kind.ready), (error) => {
|
|
67
|
+
resolve(error === void 0 || error === null);
|
|
68
|
+
});
|
|
69
|
+
} catch {
|
|
70
|
+
resolve(false);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
async function run() {
|
|
76
|
+
validateDescriptors();
|
|
77
|
+
const payloadUnitName = process.argv[2];
|
|
78
|
+
if (!payloadUnitName || !/^pf-systemd-tasks-observed-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\.service$/.test(payloadUnitName)) fail("payload unit name is invalid.");
|
|
79
|
+
const stdout = new net.Socket({ fd: 3, readable: true, writable: false });
|
|
80
|
+
const stderr = new net.Socket({ fd: 4, readable: true, writable: false });
|
|
81
|
+
const observer = new net.Socket({ fd: 5, readable: true, writable: true, allowHalfOpen: true });
|
|
82
|
+
const sources = [
|
|
83
|
+
{ stream: stdout, kind: kind.stdout, ended: false, pending: null },
|
|
84
|
+
{ stream: stderr, kind: kind.stderr, ended: false, pending: null }
|
|
85
|
+
];
|
|
86
|
+
let attached = true;
|
|
87
|
+
let blocked = false;
|
|
88
|
+
let pumping = false;
|
|
89
|
+
let finishing = false;
|
|
90
|
+
let nextSourceIndex = 0;
|
|
91
|
+
let readyFrameSettled = false;
|
|
92
|
+
function detach() {
|
|
93
|
+
if (!attached) return;
|
|
94
|
+
attached = false;
|
|
95
|
+
blocked = false;
|
|
96
|
+
observer.destroy();
|
|
97
|
+
for (const source of sources) {
|
|
98
|
+
source.pending = null;
|
|
99
|
+
source.stream.off("readable", pump);
|
|
100
|
+
source.stream.resume();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function finishIfComplete() {
|
|
104
|
+
if (!readyFrameSettled || finishing || !sources.every(
|
|
105
|
+
/** Oba payload descriptory musí potvrdit EOF. */
|
|
106
|
+
(source) => source.ended
|
|
107
|
+
)) return;
|
|
108
|
+
finishing = true;
|
|
109
|
+
if (!attached) process.exit(0);
|
|
110
|
+
observer.end(
|
|
111
|
+
encode(kind.complete),
|
|
112
|
+
/** Lokální shutdown callback potvrzuje odeslaný FIN bez čekání na vzdálený close. */
|
|
113
|
+
() => process.exit(0)
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
function pump() {
|
|
117
|
+
if (pumping || blocked || !readyFrameSettled) return;
|
|
118
|
+
pumping = true;
|
|
119
|
+
try {
|
|
120
|
+
let idleSources = 0;
|
|
121
|
+
while (!blocked && idleSources < sources.length) {
|
|
122
|
+
const source = sources[nextSourceIndex];
|
|
123
|
+
nextSourceIndex = (nextSourceIndex + 1) % sources.length;
|
|
124
|
+
const available = source.pending ?? source.stream.read();
|
|
125
|
+
if (available === null) {
|
|
126
|
+
idleSources++;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
idleSources = 0;
|
|
130
|
+
const chunk = available.subarray(0, maximumPayloadBytes);
|
|
131
|
+
source.pending = available.byteLength > chunk.byteLength ? available.subarray(chunk.byteLength) : null;
|
|
132
|
+
if (attached) {
|
|
133
|
+
const writable = observer.write(encode(source.kind, chunk));
|
|
134
|
+
if (attached && !writable) blocked = true;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} finally {
|
|
138
|
+
pumping = false;
|
|
139
|
+
}
|
|
140
|
+
finishIfComplete();
|
|
141
|
+
}
|
|
142
|
+
function sourceFailure(error) {
|
|
143
|
+
stdout.destroy();
|
|
144
|
+
stderr.destroy();
|
|
145
|
+
observer.destroy();
|
|
146
|
+
process.nextTick(
|
|
147
|
+
/** Throw mimo EventEmitter callback zachová nenulový process exit. */
|
|
148
|
+
() => {
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
for (const source of sources) {
|
|
154
|
+
source.stream.on("readable", pump);
|
|
155
|
+
source.stream.once(
|
|
156
|
+
"end",
|
|
157
|
+
/** Drain zbytku před terminal EOF. */
|
|
158
|
+
() => {
|
|
159
|
+
source.ended = true;
|
|
160
|
+
pump();
|
|
161
|
+
finishIfComplete();
|
|
162
|
+
}
|
|
163
|
+
);
|
|
164
|
+
source.stream.once("error", sourceFailure);
|
|
165
|
+
}
|
|
166
|
+
observer.on(
|
|
167
|
+
"drain",
|
|
168
|
+
/** Pokračuje až po skutečném poklesu Node write fronty. */
|
|
169
|
+
() => {
|
|
170
|
+
blocked = false;
|
|
171
|
+
pump();
|
|
172
|
+
}
|
|
173
|
+
);
|
|
174
|
+
observer.on(
|
|
175
|
+
"data",
|
|
176
|
+
/** Protokol je jednosměrný; neočekávaný klientský payload znamená detach. */
|
|
177
|
+
detach
|
|
178
|
+
);
|
|
179
|
+
observer.once("end", detach);
|
|
180
|
+
observer.once("close", detach);
|
|
181
|
+
observer.once("error", detach);
|
|
182
|
+
process.on(
|
|
183
|
+
"SIGTERM",
|
|
184
|
+
/** Jen pre-exec cancellation postrádá skutečné EOF, protože manager dál vlastní write endpointy. */
|
|
185
|
+
() => {
|
|
186
|
+
if (payloadWasInvoked(payloadUnitName) !== false) {
|
|
187
|
+
pump();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
for (const source of sources) {
|
|
191
|
+
source.ended = true;
|
|
192
|
+
source.pending = null;
|
|
193
|
+
source.stream.destroy();
|
|
194
|
+
}
|
|
195
|
+
finishIfComplete();
|
|
196
|
+
}
|
|
197
|
+
);
|
|
198
|
+
notifyReady();
|
|
199
|
+
if (!await writeReady(observer)) detach();
|
|
200
|
+
readyFrameSettled = true;
|
|
201
|
+
if (attached) observer.resume();
|
|
202
|
+
pump();
|
|
203
|
+
}
|
|
204
|
+
void run().catch(
|
|
205
|
+
/** Top-level startup failure musí zachovat nenulový guardian exit. */
|
|
206
|
+
(error) => {
|
|
207
|
+
process.nextTick(() => {
|
|
208
|
+
throw error;
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
);
|
|
212
|
+
//# sourceMappingURL=guardian.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/guardian.ts","../src/observed-framing.ts"],"sourcesContent":["import * as childProcess from 'node:child_process';\nimport * as fs from 'node:fs';\nimport * as net from 'node:net';\nimport * as framing from './observed-framing';\n\n/** Jeden guardian input drží svůj framing kind a případný zbytek velkého stream readu. */\ninterface Source {\n\t/** Raw task stream vlastněný tímto guardian procesem. */\n\treadonly stream: net.Socket;\n\t/** Output kind zachovávající oddělení stdout a stderr. */\n\treadonly kind: typeof framing.kind.stdout | typeof framing.kind.stderr;\n\t/** EOF je autoritativní až po vyčerpání kernel bufferu. */\n\tended: boolean;\n\t/** Zbytek většího readu čekající na další bounded frame. */\n\tpending: Buffer | null;\n}\n\n/** Ukončí guardian fail-closed bez zápisu citlivých output dat na jeho stdio. */\nfunction fail(message: string, cause?: unknown): never {\n\tthrow new Error(`Observed task guardian failed: ${message}`, cause === undefined ? undefined : { cause });\n}\n\n/** Ověří přesný systemd fd-store kontrakt dřív, než guardian oznámí readiness. */\nfunction validateDescriptors(): void {\n\tif (process.env.LISTEN_PID !== String(process.pid)) fail('LISTEN_PID does not identify this process.');\n\tif (process.env.LISTEN_FDS !== '3') fail('LISTEN_FDS is not exactly three.');\n\tif (process.env.LISTEN_FDNAMES !== 'task-stdout:task-stderr:observer') fail('LISTEN_FDNAMES has an unexpected order.');\n\tfor (const descriptor of [3, 4, 5]) if (!fs.fstatSync(descriptor).isSocket()) fail(`descriptor ${descriptor} is not a socket.`);\n\tdelete process.env.LISTEN_PID;\n\tdelete process.env.LISTEN_FDS;\n\tdelete process.env.LISTEN_FDNAMES;\n}\n\n/** Potvrdí systemd readiness synchronně, aby ready frame nikdy nepředběhl selhání notify. */\nfunction notifyReady(): void {\n\tconst result = childProcess.spawnSync('/usr/bin/systemd-notify', ['--ready', '--pid=parent'], {\n\t\tenv: process.env,\n\t\tstdio: ['ignore', 'pipe', 'pipe'],\n\t\tencoding: 'utf8',\n\t\ttimeout: 5000,\n\t});\n\tif (result.error) fail('systemd-notify could not execute.', result.error);\n\tif (result.status !== 0) fail(`systemd-notify exited with status ${String(result.status)}.`);\n}\n\n/** Přečte payload InvocationID až po ordered stopu; prázdná hodnota spolehlivě značí zrušení před exec. */\nfunction payloadWasInvoked(unitName: string): boolean | null {\n\tif (process.getuid === undefined) return null;\n\tconst runtimeDirectory = `/run/user/${String(process.getuid())}`;\n\tconst result = childProcess.spawnSync('/usr/bin/systemctl', ['--user', 'show', unitName, '--property=InvocationID', '--value'], {\n\t\tenv: { XDG_RUNTIME_DIR: runtimeDirectory, DBUS_SESSION_BUS_ADDRESS: `unix:path=${runtimeDirectory}/bus` },\n\t\tstdio: ['ignore', 'pipe', 'pipe'],\n\t\tencoding: 'utf8',\n\t\ttimeout: 5000,\n\t\tmaxBuffer: 4096,\n\t});\n\tif (result.error || result.status !== 0) return null;\n\tconst invocationId = result.stdout.trim();\n\tif (invocationId === '') return false;\n\treturn /^[0-9a-f]{32}$/.test(invocationId) ? true : null;\n}\n\n/** Rozliší lokální acceptance ready frame od observeru odpojeného během startupu. */\nasync function writeReady(observer: net.Socket): Promise<boolean> {\n\treturn await new Promise<boolean>(/** Chyba client-facing socketu znamená detach, nikoliv selhání guardian infrastruktury. */ resolve => {\n\t\ttry { observer.write(framing.encode(framing.kind.ready), error => { resolve(error === undefined || error === null); }); }\n\t\tcatch { resolve(false); }\n\t});\n}\n\n/** Drží output pipes průchozí po detach a končí až po EOF obou payload kanálů. */\nasync function run(): Promise<void> {\n\tvalidateDescriptors();\n\tconst payloadUnitName = process.argv[2];\n\tif (!payloadUnitName || !/^pf-systemd-tasks-observed-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\\.service$/.test(payloadUnitName)) fail('payload unit name is invalid.');\n\tconst stdout = new net.Socket({ fd: 3, readable: true, writable: false });\n\tconst stderr = new net.Socket({ fd: 4, readable: true, writable: false });\n\tconst observer = new net.Socket({ fd: 5, readable: true, writable: true, allowHalfOpen: true });\n\tconst sources: Source[] = [\n\t\t{ stream: stdout, kind: framing.kind.stdout, ended: false, pending: null },\n\t\t{ stream: stderr, kind: framing.kind.stderr, ended: false, pending: null },\n\t];\n\tlet attached = true;\n\tlet blocked = false;\n\tlet pumping = false;\n\tlet finishing = false;\n\tlet nextSourceIndex = 0;\n\tlet readyFrameSettled = false;\n\n\t/** Přepne nevratně do drain/discard a uvolní output backpressure payloadu. */\n\tfunction detach(): void {\n\t\tif (!attached) return;\n\t\tattached = false;\n\t\tblocked = false;\n\t\tobserver.destroy();\n\t\tfor (const source of sources) {\n\t\t\tsource.pending = null;\n\t\t\tsource.stream.off('readable', pump);\n\t\t\tsource.stream.resume();\n\t\t}\n\t}\n\n\t/** Po obou EOF odešle jediný complete frame, nebo po detach prostě dovolí exit. */\n\tfunction finishIfComplete(): void {\n\t\tif (!readyFrameSettled || finishing || !sources.every(/** Oba payload descriptory musí potvrdit EOF. */ source => source.ended)) return;\n\t\tfinishing = true;\n\t\tif (!attached) process.exit(0);\n\t\tobserver.end(framing.encode(framing.kind.complete), /** Lokální shutdown callback potvrzuje odeslaný FIN bez čekání na vzdálený close. */ () => process.exit(0));\n\t}\n\n\t/** Fair round-robin čte nejvýše bounded framy a respektuje skutečné EOF i observer write backpressure. */\n\tfunction pump(): void {\n\t\tif (pumping || blocked || !readyFrameSettled) return;\n\t\tpumping = true;\n\t\ttry {\n\t\t\tlet idleSources = 0;\n\t\t\twhile (!blocked && idleSources < sources.length) {\n\t\t\t\tconst source = sources[nextSourceIndex]!;\n\t\t\t\tnextSourceIndex = (nextSourceIndex + 1) % sources.length;\n\t\t\t\tconst available = source.pending ?? source.stream.read();\n\t\t\t\tif (available === null) { idleSources++; continue; }\n\t\t\t\tidleSources = 0;\n\t\t\t\tconst chunk = available.subarray(0, framing.maximumPayloadBytes);\n\t\t\t\tsource.pending = available.byteLength > chunk.byteLength ? available.subarray(chunk.byteLength) : null;\n\t\t\t\tif (attached) {\n\t\t\t\t\tconst writable = observer.write(framing.encode(source.kind, chunk));\n\t\t\t\t\tif (attached && !writable) blocked = true;\n\t\t\t\t}\n\t\t\t}\n\t\t} finally { pumping = false; }\n\t\tfinishIfComplete();\n\t}\n\n\t/** Zdrojová chyba musí shodit guardian a přes BindsTo fail-closed zastavit payload. */\n\tfunction sourceFailure(error: Error): void {\n\t\tstdout.destroy();\n\t\tstderr.destroy();\n\t\tobserver.destroy();\n\t\tprocess.nextTick(/** Throw mimo EventEmitter callback zachová nenulový process exit. */ () => { throw error; });\n\t}\n\n\tfor (const source of sources) {\n\t\tsource.stream.on('readable', pump);\n\t\tsource.stream.once('end', /** Drain zbytku před terminal EOF. */ () => { source.ended = true; pump(); finishIfComplete(); });\n\t\tsource.stream.once('error', sourceFailure);\n\t}\n\tobserver.on('drain', /** Pokračuje až po skutečném poklesu Node write fronty. */ () => { blocked = false; pump(); });\n\tobserver.on('data', /** Protokol je jednosměrný; neočekávaný klientský payload znamená detach. */ detach);\n\tobserver.once('end', detach);\n\tobserver.once('close', detach);\n\tobserver.once('error', detach);\n\tprocess.on('SIGTERM', /** Jen pre-exec cancellation postrádá skutečné EOF, protože manager dál vlastní write endpointy. */ () => {\n\t\tif (payloadWasInvoked(payloadUnitName) !== false) { pump(); return; }\n\t\tfor (const source of sources) {\n\t\t\tsource.ended = true;\n\t\t\tsource.pending = null;\n\t\t\tsource.stream.destroy();\n\t\t}\n\t\tfinishIfComplete();\n\t});\n\n\tnotifyReady();\n\tif (!await writeReady(observer)) detach();\n\treadyFrameSettled = true;\n\tif (attached) observer.resume();\n\tpump();\n}\n\nvoid run().catch(/** Top-level startup failure musí zachovat nenulový guardian exit. */ error => { process.nextTick(() => { throw error; }); });\n","/** Čtyřbytový magic odděluje guardian stream od náhodného nebo staršího protokolu. */\nexport const magic = Buffer.from('SDTO', 'ascii');\n/** První veřejně implementovaná verze interního guardian protokolu. */\nexport const version = 1;\n/** Fixní hlavička obsahuje magic, verzi, kind a uint32be délku. */\nexport const headerBytes = 10;\n/** Jeden payload frame je bounded nezávisle na Node stream high-water marku. */\nexport const maximumPayloadBytes = 64 * 1024;\n\n/** Wire hodnoty jsou stabilní a nesmějí záviset na pořadí TypeScript enumu. */\nexport const kind = Object.freeze({ ready: 1, stdout: 2, stderr: 3, complete: 4 } as const);\n\n/** Zakóduje jeden již bounded frame pro guardian write frontu. */\nexport function encode(frameKind: number, payload: Uint8Array = new Uint8Array()): Buffer {\n\tif (!Object.values(kind).includes(frameKind as 1 | 2 | 3 | 4) || payload.byteLength > maximumPayloadBytes) throw new Error('Invalid observed guardian frame.');\n\tconst frame = Buffer.allocUnsafe(headerBytes + payload.byteLength);\n\tmagic.copy(frame, 0);\n\tframe[4] = version;\n\tframe[5] = frameKind;\n\tframe.writeUInt32BE(payload.byteLength, 6);\n\tframe.set(payload, headerBytes);\n\treturn frame;\n}\n"],"mappings":"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","names":[]}
|