@aliou/pi-processes 0.11.2 → 0.12.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/extensions/processes/handlers/commands.ts +25 -1
- package/extensions/shared/protocol/channels.ts +3 -0
- package/extensions/shared/protocol/commands.ts +26 -0
- package/extensions/shared/protocol/index.ts +2 -0
- package/package.json +1 -1
- package/src/manager/index.ts +18 -0
- package/src/manager/process-log-store.ts +1 -20
- package/src/manager/process-output.ts +1 -22
- package/src/manager/process-runtime-controller.ts +99 -4
- package/src/types.ts +10 -0
- package/src/utils/buffer.ts +28 -0
- package/src/utils/index.ts +1 -0
|
@@ -4,6 +4,7 @@ import type { ProcessManager } from "../../../src/manager";
|
|
|
4
4
|
import type { KillResult } from "../../../src/types";
|
|
5
5
|
import {
|
|
6
6
|
CHANNELS,
|
|
7
|
+
type CommandAdoptPayload,
|
|
7
8
|
type CommandClearPayload,
|
|
8
9
|
type CommandKillPayload,
|
|
9
10
|
type CommandStartPayload,
|
|
@@ -51,13 +52,36 @@ export function registerCommandHandlers(
|
|
|
51
52
|
|
|
52
53
|
safeReply(command.reply, manager.clearFinished());
|
|
53
54
|
}),
|
|
55
|
+
events.on(CHANNELS.COMMAND_ADOPT, (payload) => {
|
|
56
|
+
const command = payload as CommandAdoptPayload;
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const info = manager.adopt(
|
|
60
|
+
command.name,
|
|
61
|
+
command.command,
|
|
62
|
+
command.cwd,
|
|
63
|
+
command.child,
|
|
64
|
+
{
|
|
65
|
+
initialStdout: command.initialStdout,
|
|
66
|
+
initialStderr: command.initialStderr,
|
|
67
|
+
startTime: command.startTime,
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
notifications.register(info.id, {});
|
|
71
|
+
safeReply(command.reply, { ok: true, info });
|
|
72
|
+
} catch (error) {
|
|
73
|
+
safeReply(command.reply, {
|
|
74
|
+
ok: false,
|
|
75
|
+
error: error instanceof Error ? error.message : String(error),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}),
|
|
54
79
|
];
|
|
55
80
|
|
|
56
81
|
return () => {
|
|
57
82
|
for (const dispose of disposers) dispose();
|
|
58
83
|
};
|
|
59
84
|
}
|
|
60
|
-
|
|
61
85
|
function safeReply<T>(reply: (result: T) => void, result: T): void {
|
|
62
86
|
try {
|
|
63
87
|
reply(result);
|
|
@@ -18,6 +18,9 @@ export const CHANNELS = {
|
|
|
18
18
|
COMMAND_START: "processes:command:start",
|
|
19
19
|
COMMAND_KILL: "processes:command:kill",
|
|
20
20
|
COMMAND_CLEAR: "processes:command:clear",
|
|
21
|
+
// Other extensions emit this to hand an already-running child process
|
|
22
|
+
// over to the manager (e.g. backgrounding a foreground tool command).
|
|
23
|
+
COMMAND_ADOPT: "processes:command:adopt",
|
|
21
24
|
// Pin handled by the dock extension, if loaded.
|
|
22
25
|
COMMAND_PIN: "processes:command:pin",
|
|
23
26
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ChildProcess } from "node:child_process";
|
|
2
|
+
|
|
1
3
|
import type { KillResult, ProcessInfo } from "../../../src/types";
|
|
2
4
|
|
|
3
5
|
// UI emits, core handles then calls reply.
|
|
@@ -33,3 +35,27 @@ export interface CommandPinPayload {
|
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export type CommandPinResult = { ok: true } | { ok: false; error: string };
|
|
38
|
+
|
|
39
|
+
// Another extension emits this to hand an already-running child process over
|
|
40
|
+
// to the manager. The child must have been spawned in a detached process
|
|
41
|
+
// group (`detached: true`) with piped stdio — the same shape the manager's
|
|
42
|
+
// own spawns use — so group kill and liveness polling work on it. Payloads
|
|
43
|
+
// cross the event bus by reference, so the live ChildProcess handle arrives
|
|
44
|
+
// intact. If the processes extension is not loaded, no listener replies.
|
|
45
|
+
export interface CommandAdoptPayload {
|
|
46
|
+
name: string;
|
|
47
|
+
command: string;
|
|
48
|
+
cwd: string;
|
|
49
|
+
child: ChildProcess;
|
|
50
|
+
/** Stdout captured before handover. */
|
|
51
|
+
initialStdout?: Buffer;
|
|
52
|
+
/** Stderr captured before handover. */
|
|
53
|
+
initialStderr?: Buffer;
|
|
54
|
+
/** When the command actually started (epoch ms). */
|
|
55
|
+
startTime?: number;
|
|
56
|
+
reply: (result: CommandAdoptResult) => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type CommandAdoptResult =
|
|
60
|
+
| { ok: true; info: ProcessInfo }
|
|
61
|
+
| { ok: false; error: string };
|
package/package.json
CHANGED
package/src/manager/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import type { ChildProcess } from "node:child_process";
|
|
1
2
|
import { EventEmitter } from "node:events";
|
|
2
3
|
|
|
3
4
|
import type {
|
|
5
|
+
AdoptProcessOptions,
|
|
4
6
|
KillResult,
|
|
5
7
|
ManagerEvent,
|
|
6
8
|
ProcessInfo,
|
|
@@ -57,6 +59,22 @@ export class ProcessManager {
|
|
|
57
59
|
return formatProcess(managed);
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Adopt an externally spawned child process. The child must run in a
|
|
64
|
+
* detached process group with piped stdio (see
|
|
65
|
+
* ProcessRuntimeController.adopt for the full contract).
|
|
66
|
+
*/
|
|
67
|
+
adopt(
|
|
68
|
+
name: string,
|
|
69
|
+
command: string,
|
|
70
|
+
cwd: string,
|
|
71
|
+
child: ChildProcess,
|
|
72
|
+
opts?: AdoptProcessOptions,
|
|
73
|
+
): ProcessInfo {
|
|
74
|
+
const managed = this.runtime.adopt(name, command, cwd, child, opts);
|
|
75
|
+
return formatProcess(managed);
|
|
76
|
+
}
|
|
77
|
+
|
|
60
78
|
list(): ProcessInfo[] {
|
|
61
79
|
return this.registry.list();
|
|
62
80
|
}
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "node:fs";
|
|
14
14
|
import { tmpdir } from "node:os";
|
|
15
15
|
import { join } from "node:path";
|
|
16
|
-
|
|
16
|
+
import { trimIncompleteUtf8Suffix } from "../utils/buffer";
|
|
17
17
|
import type { ProcessLogPaths } from "./internal-types";
|
|
18
18
|
import { MAX_LOG_FILE_BYTES, MAX_TAIL_READ_BYTES } from "./limits";
|
|
19
19
|
|
|
@@ -401,22 +401,3 @@ function decodeUtf8Bounded(buffer: Buffer, maxOutputBytes: number): string {
|
|
|
401
401
|
|
|
402
402
|
return parts.join("");
|
|
403
403
|
}
|
|
404
|
-
|
|
405
|
-
function trimIncompleteUtf8Suffix(buffer: Buffer): Buffer {
|
|
406
|
-
if (buffer.length === 0) return buffer;
|
|
407
|
-
let lead = buffer.length - 1;
|
|
408
|
-
while (lead >= 0 && (buffer[lead] & 0xc0) === 0x80) lead--;
|
|
409
|
-
if (lead < 0) return Buffer.alloc(0);
|
|
410
|
-
const byte = buffer[lead];
|
|
411
|
-
const expected =
|
|
412
|
-
byte < 0x80
|
|
413
|
-
? 1
|
|
414
|
-
: (byte & 0xe0) === 0xc0
|
|
415
|
-
? 2
|
|
416
|
-
: (byte & 0xf0) === 0xe0
|
|
417
|
-
? 3
|
|
418
|
-
: (byte & 0xf8) === 0xf0
|
|
419
|
-
? 4
|
|
420
|
-
: 1;
|
|
421
|
-
return buffer.length - lead < expected ? buffer.subarray(0, lead) : buffer;
|
|
422
|
-
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ManagerEvent } from "../types";
|
|
2
|
+
import { trimIncompleteUtf8Suffix } from "../utils/buffer";
|
|
2
3
|
import type { ManagedProcessRecord } from "./internal-types";
|
|
3
4
|
import {
|
|
4
5
|
MAX_LINE_BYTES,
|
|
@@ -321,25 +322,3 @@ export class ProcessOutput {
|
|
|
321
322
|
this.clearAll();
|
|
322
323
|
}
|
|
323
324
|
}
|
|
324
|
-
|
|
325
|
-
function trimIncompleteUtf8Suffix(buffer: Buffer): Buffer {
|
|
326
|
-
if (buffer.length === 0) return buffer;
|
|
327
|
-
|
|
328
|
-
let lead = buffer.length - 1;
|
|
329
|
-
while (lead >= 0 && (buffer[lead] & 0xc0) === 0x80) lead--;
|
|
330
|
-
if (lead < 0) return Buffer.alloc(0);
|
|
331
|
-
|
|
332
|
-
const leadByte = buffer[lead];
|
|
333
|
-
const expectedLength =
|
|
334
|
-
leadByte < 0x80
|
|
335
|
-
? 1
|
|
336
|
-
: (leadByte & 0xe0) === 0xc0
|
|
337
|
-
? 2
|
|
338
|
-
: (leadByte & 0xf0) === 0xe0
|
|
339
|
-
? 3
|
|
340
|
-
: (leadByte & 0xf8) === 0xf0
|
|
341
|
-
? 4
|
|
342
|
-
: 1;
|
|
343
|
-
const actualLength = buffer.length - lead;
|
|
344
|
-
return actualLength < expectedLength ? buffer.subarray(0, lead) : buffer;
|
|
345
|
-
}
|
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
import type { ChildProcess } from "node:child_process";
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
AdoptProcessOptions,
|
|
5
|
+
KillResult,
|
|
6
|
+
ManagerEvent,
|
|
7
|
+
WriteResult,
|
|
8
|
+
} from "../types";
|
|
4
9
|
import { LIVE_STATUSES } from "../types";
|
|
5
10
|
import { isProcessGroupAlive, killProcessGroup } from "../utils";
|
|
11
|
+
import { clampToTail } from "../utils/buffer";
|
|
6
12
|
import { spawnCommand } from "../utils/command-executor";
|
|
7
13
|
import { formatSignalInfo } from "../utils/signals";
|
|
8
14
|
import type { ManagedProcessRecord } from "./internal-types";
|
|
9
15
|
import { formatProcess } from "./internal-types";
|
|
10
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
FINISHED_RECORD_GRACE_MS,
|
|
18
|
+
MAX_FINISHED_RECORDS,
|
|
19
|
+
MAX_TAIL_READ_BYTES,
|
|
20
|
+
} from "./limits";
|
|
11
21
|
import type { ProcessLogStore } from "./process-log-store";
|
|
12
22
|
import type { ProcessOutput } from "./process-output";
|
|
13
23
|
import type { ProcessRegistry } from "./process-registry";
|
|
@@ -41,10 +51,45 @@ export class ProcessRuntimeController {
|
|
|
41
51
|
}
|
|
42
52
|
|
|
43
53
|
start(name: string, command: string, cwd: string): ManagedProcessRecord {
|
|
54
|
+
const child = spawnCommand(command, cwd, this.getConfiguredShellPath());
|
|
55
|
+
return this.register(name, command, cwd, child, {});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Adopt an externally spawned child process into the manager.
|
|
60
|
+
*
|
|
61
|
+
* The child must have been spawned like `spawnCommand` spawns: in a
|
|
62
|
+
* detached process group (`detached: true`) with piped stdio, so group
|
|
63
|
+
* kill and liveness polling behave identically to started processes.
|
|
64
|
+
*
|
|
65
|
+
* `initialStdout` / `initialStderr` are pre-handover output, each
|
|
66
|
+
* prepended to the matching log and clamped to MAX_TAIL_READ_BYTES.
|
|
67
|
+
* `startTime` backdates the record to when the command actually began.
|
|
68
|
+
*/
|
|
69
|
+
adopt(
|
|
70
|
+
name: string,
|
|
71
|
+
command: string,
|
|
72
|
+
cwd: string,
|
|
73
|
+
child: ChildProcess,
|
|
74
|
+
opts?: AdoptProcessOptions,
|
|
75
|
+
): ManagedProcessRecord {
|
|
76
|
+
return this.register(name, command, cwd, child, {
|
|
77
|
+
initialStdout: opts?.initialStdout,
|
|
78
|
+
initialStderr: opts?.initialStderr,
|
|
79
|
+
startTime: opts?.startTime,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private register(
|
|
84
|
+
name: string,
|
|
85
|
+
command: string,
|
|
86
|
+
cwd: string,
|
|
87
|
+
child: ChildProcess,
|
|
88
|
+
opts: AdoptProcessOptions,
|
|
89
|
+
): ManagedProcessRecord {
|
|
44
90
|
const id = this.registry.nextId();
|
|
45
91
|
const logPaths = this.logs.createLogs(id);
|
|
46
92
|
|
|
47
|
-
const child = spawnCommand(command, cwd, this.getConfiguredShellPath());
|
|
48
93
|
// Spawned commands run in detached process groups so TERM/KILL can target
|
|
49
94
|
// the whole tree. `unref()` keeps the manager's Node process from staying
|
|
50
95
|
// alive only because a managed child still exists; extension shutdown and
|
|
@@ -57,7 +102,7 @@ export class ProcessRuntimeController {
|
|
|
57
102
|
pid: child.pid ?? -1,
|
|
58
103
|
command,
|
|
59
104
|
cwd,
|
|
60
|
-
startTime: Date.now(),
|
|
105
|
+
startTime: opts.startTime ?? Date.now(),
|
|
61
106
|
endTime: null,
|
|
62
107
|
status: "running",
|
|
63
108
|
exitCode: null,
|
|
@@ -107,14 +152,64 @@ export class ProcessRuntimeController {
|
|
|
107
152
|
return managed;
|
|
108
153
|
}
|
|
109
154
|
|
|
155
|
+
if (opts.initialStdout && opts.initialStdout.length > 0) {
|
|
156
|
+
const clamped = clampToTail(opts.initialStdout, MAX_TAIL_READ_BYTES);
|
|
157
|
+
this.logs.appendStdout(managed.stdoutFile, clamped);
|
|
158
|
+
this.output.onStdoutChunk(managed, clamped);
|
|
159
|
+
}
|
|
160
|
+
if (opts.initialStderr && opts.initialStderr.length > 0) {
|
|
161
|
+
const clamped = clampToTail(opts.initialStderr, MAX_TAIL_READ_BYTES);
|
|
162
|
+
this.logs.appendStderr(managed.stderrFile, clamped);
|
|
163
|
+
this.output.onStderrChunk(managed, clamped);
|
|
164
|
+
}
|
|
165
|
+
|
|
110
166
|
this.wireStdioHandlers(managed, child);
|
|
111
167
|
|
|
112
168
|
this.emit({ type: "process_started", info: formatProcess(managed) });
|
|
169
|
+
this.finalizeIfAlreadyClosed(managed, child);
|
|
113
170
|
this.ensureWatcherRunning();
|
|
114
171
|
|
|
115
172
|
return managed;
|
|
116
173
|
}
|
|
117
174
|
|
|
175
|
+
/**
|
|
176
|
+
* An adopted child may have fully exited (close event fired) before its
|
|
177
|
+
* handlers were attached here. In that case no close event will ever
|
|
178
|
+
* reach wireStdioHandlers, so replay the close classification directly.
|
|
179
|
+
* If the child exited but its streams are still open, the pending close
|
|
180
|
+
* event will finalize the record through the normal path.
|
|
181
|
+
*/
|
|
182
|
+
private finalizeIfAlreadyClosed(
|
|
183
|
+
managed: ManagedProcessRecord,
|
|
184
|
+
child: ChildProcess,
|
|
185
|
+
): void {
|
|
186
|
+
const exited = child.exitCode !== null || child.signalCode !== null;
|
|
187
|
+
if (!exited) return;
|
|
188
|
+
|
|
189
|
+
const streamsDone =
|
|
190
|
+
(child.stdout?.destroyed ?? true) && (child.stderr?.destroyed ?? true);
|
|
191
|
+
if (!streamsDone) return;
|
|
192
|
+
|
|
193
|
+
this.releaseRuntimeHandles(managed);
|
|
194
|
+
if (managed.endTime) return;
|
|
195
|
+
|
|
196
|
+
managed.exitCode = child.exitCode;
|
|
197
|
+
managed.endTime = Date.now();
|
|
198
|
+
this.output.flush(managed);
|
|
199
|
+
|
|
200
|
+
if (child.signalCode) {
|
|
201
|
+
managed.success = false;
|
|
202
|
+
managed.endReason = "signal";
|
|
203
|
+
managed.signal = formatSignalInfo(child.signalCode);
|
|
204
|
+
this.transition(managed, "killed");
|
|
205
|
+
} else {
|
|
206
|
+
managed.success = child.exitCode === 0;
|
|
207
|
+
managed.endReason = "exit";
|
|
208
|
+
managed.signal = null;
|
|
209
|
+
this.transition(managed, "exited");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
118
213
|
transition(managed: ManagedProcessRecord, next: typeof managed.status): void {
|
|
119
214
|
if (managed.status === next) return;
|
|
120
215
|
managed.status = next;
|
package/src/types.ts
CHANGED
|
@@ -54,6 +54,16 @@ export type ManagerEvent =
|
|
|
54
54
|
}
|
|
55
55
|
| { type: "processes_changed" };
|
|
56
56
|
|
|
57
|
+
/** Options for adopting an externally spawned child process. */
|
|
58
|
+
export interface AdoptProcessOptions {
|
|
59
|
+
/** Pre-handover stdout; prepended to the stdout log, clamped to MAX_TAIL_READ_BYTES. */
|
|
60
|
+
initialStdout?: Buffer;
|
|
61
|
+
/** Pre-handover stderr; prepended to the stderr log, clamped to MAX_TAIL_READ_BYTES. */
|
|
62
|
+
initialStderr?: Buffer;
|
|
63
|
+
/** When the command actually started (epoch ms). Defaults to adoption time. */
|
|
64
|
+
startTime?: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
57
67
|
export type KillResult =
|
|
58
68
|
| { ok: true; info: ProcessInfo }
|
|
59
69
|
| { ok: false; info: ProcessInfo; reason: "not_found" | "timeout" | "error" };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Trim trailing bytes that form an incomplete UTF-8 code point. */
|
|
2
|
+
export function trimIncompleteUtf8Suffix(buffer: Buffer): Buffer {
|
|
3
|
+
if (buffer.length === 0) return buffer;
|
|
4
|
+
|
|
5
|
+
let lead = buffer.length - 1;
|
|
6
|
+
while (lead >= 0 && (buffer[lead] & 0xc0) === 0x80) lead--;
|
|
7
|
+
if (lead < 0) return Buffer.alloc(0);
|
|
8
|
+
|
|
9
|
+
const leadByte = buffer[lead];
|
|
10
|
+
const expectedLength =
|
|
11
|
+
leadByte < 0x80
|
|
12
|
+
? 1
|
|
13
|
+
: (leadByte & 0xe0) === 0xc0
|
|
14
|
+
? 2
|
|
15
|
+
: (leadByte & 0xf0) === 0xe0
|
|
16
|
+
? 3
|
|
17
|
+
: (leadByte & 0xf8) === 0xf0
|
|
18
|
+
? 4
|
|
19
|
+
: 1;
|
|
20
|
+
const actualLength = buffer.length - lead;
|
|
21
|
+
return actualLength < expectedLength ? buffer.subarray(0, lead) : buffer;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Keep the last `maxBytes` of a buffer, trimming any incomplete UTF-8 sequence. */
|
|
25
|
+
export function clampToTail(buf: Buffer, maxBytes: number): Buffer {
|
|
26
|
+
if (buf.length <= maxBytes) return buf;
|
|
27
|
+
return trimIncompleteUtf8Suffix(buf.subarray(buf.length - maxBytes));
|
|
28
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { hasAnsi, stripAnsi } from "./ansi";
|
|
2
|
+
export { clampToTail, trimIncompleteUtf8Suffix } from "./buffer";
|
|
2
3
|
export { resolveShellExecutable, spawnCommand } from "./command-executor";
|
|
3
4
|
export { formatRuntime, formatStatus, formatTimestamp } from "./format";
|
|
4
5
|
export type { LineMatchMode } from "./match-line";
|