pi-simple-background-tasks 0.1.0 → 0.3.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/FORK-CHANGES.md +13 -6
- package/README.md +20 -10
- package/package.json +4 -3
- package/src/core/common.ts +26 -207
- package/src/core/detached-spawn.ts +317 -0
- package/src/core/registry.ts +586 -1203
- package/src/core/task-cleanup.ts +6 -0
- package/src/core/windows-taskkill.ts +4 -11
- package/src/core/windows-wmi-spawn.ts +192 -0
- package/src/extension.ts +123 -225
- package/src/ui/background-tasks-manager.ts +156 -35
- package/src/core/reload-shell-owner.ts +0 -1512
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { spawn as nodeSpawn, type SpawnOptions } from 'node:child_process';
|
|
2
|
+
import { closeSync, openSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { type ResolvedShellPolicy, shellQuote } from './common.js';
|
|
5
|
+
import { writeFileFsynced } from './task-durable.js';
|
|
6
|
+
import { createProcessViaWmi } from './windows-wmi-spawn.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Detached background-task spawning: the single launch path used by every task.
|
|
10
|
+
*
|
|
11
|
+
* The extension process never owns the child at runtime. There are no pipes, no
|
|
12
|
+
* in-process output stream, and no handle that a same-process /reload could
|
|
13
|
+
* lose. The ownership facts live outside the extension:
|
|
14
|
+
*
|
|
15
|
+
* - POSIX: the child starts fully detached, so it leads its own process group
|
|
16
|
+
* (`pgid === pid`) and signal authority is `-pgid`, valid from any process.
|
|
17
|
+
* - Windows: the outer process is created by the WMI service, so the task is
|
|
18
|
+
* outside every process job and console session owned by the Pi activation
|
|
19
|
+
* and outlives it (restart, /reload-style reattachments, even a hard kill).
|
|
20
|
+
* Ownership of the tree is `taskkill /T /F` via the recorded pid. Output is
|
|
21
|
+
* redirected by the spawn's own command line (the extension holds no fd).
|
|
22
|
+
* - Termination: the wrapper writes `<id>.exit` when the user command settles.
|
|
23
|
+
* Until and unless that marker exists, a running task's process liveness is
|
|
24
|
+
* probed via the recorded pid; the marker is the terminal source of truth.
|
|
25
|
+
*
|
|
26
|
+
* The registry can therefore adopt a running task purely from its on-disk
|
|
27
|
+
* metadata, including tasks launched by an earlier extension activation (or an
|
|
28
|
+
* earlier Pi process entirely).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export const EXIT_MARKER_SUFFIX = '.exit';
|
|
32
|
+
export const RUNNER_SCRIPT_SUFFIX = '.runner.cmd';
|
|
33
|
+
|
|
34
|
+
export type DetachedWrapMode = 'posix' | 'cmd' | 'none';
|
|
35
|
+
|
|
36
|
+
export interface DetachedSpawnChild {
|
|
37
|
+
pid?: number | undefined;
|
|
38
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
39
|
+
unref?(): unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type DetachedSpawnFn = (
|
|
43
|
+
command: string,
|
|
44
|
+
args: string[],
|
|
45
|
+
options: SpawnOptions,
|
|
46
|
+
) => DetachedSpawnChild;
|
|
47
|
+
|
|
48
|
+
export interface SpawnDetachedTaskOptions {
|
|
49
|
+
readonly id: string;
|
|
50
|
+
readonly taskDirAbs: string;
|
|
51
|
+
readonly command: string;
|
|
52
|
+
readonly policy: ResolvedShellPolicy;
|
|
53
|
+
readonly cwd: string;
|
|
54
|
+
readonly env: NodeJS.ProcessEnv;
|
|
55
|
+
readonly spawn?: DetachedSpawnFn | undefined;
|
|
56
|
+
readonly platform?: NodeJS.Platform | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SpawnDetachedTaskResult {
|
|
60
|
+
readonly exitMarkerFile: string;
|
|
61
|
+
readonly exitMarkerTrusted: boolean;
|
|
62
|
+
readonly runnerScriptFile?: string | undefined;
|
|
63
|
+
readonly pid: number;
|
|
64
|
+
readonly posixProcessGroupId?: number | undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function exitMarkerPath(taskDirAbs: string, id: string): string {
|
|
68
|
+
return join(taskDirAbs, `${id}${EXIT_MARKER_SUFFIX}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function runnerScriptPath(taskDirAbs: string, id: string): string {
|
|
72
|
+
return join(taskDirAbs, `${id}${RUNNER_SCRIPT_SUFFIX}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* POSIX wrapper: run the user command in a subshell, then record its exit code
|
|
77
|
+
* in the marker file. The marker is written before the wrapper exits, so the
|
|
78
|
+
* marker always exists when the process has settled with a recon code.
|
|
79
|
+
*/
|
|
80
|
+
export function buildPosixWrappedCommand(command: string, exitMarkerFile: string): string {
|
|
81
|
+
return [
|
|
82
|
+
`( ${command} );`,
|
|
83
|
+
'bgCode=$?;',
|
|
84
|
+
`printf '%s\\n' "$bgCode" > ${shellQuote(exitMarkerFile)};`,
|
|
85
|
+
'exit "$bgCode"',
|
|
86
|
+
].join(' ');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Windows runner script: cmd processes each line independently, so a stray
|
|
91
|
+
* parenthesis inside the user command cannot affect the epilogue. The
|
|
92
|
+
* parenthesized `echo` prevents `%errorlevel%` digits being parsed as handle
|
|
93
|
+
* redirections (`echo 1>file` redirects handle 1 and echoes an empty line).
|
|
94
|
+
*
|
|
95
|
+
* The user command runs under a nested `cmd /d /s /c "<command>"` instead of
|
|
96
|
+
* directly in the runner: a bare `exit N` in the user command terminates the
|
|
97
|
+
* whole cmd instance and would skip the epilogue lines entirely, while a
|
|
98
|
+
* nested instance only sets the errorlevel for the runner to record. The
|
|
99
|
+
* quoting follows the same `/s /c "<command>"` contract as
|
|
100
|
+
* `shellInvocationForPolicy`, arg-for-arg.
|
|
101
|
+
*/
|
|
102
|
+
export function buildWindowsRunnerScript(
|
|
103
|
+
command: string,
|
|
104
|
+
exitMarkerFile: string,
|
|
105
|
+
shellExecutable: string,
|
|
106
|
+
): string {
|
|
107
|
+
return [
|
|
108
|
+
'@echo off',
|
|
109
|
+
`"${shellExecutable}" /d /s /c "${command}"`,
|
|
110
|
+
`(echo %errorlevel%)>"${exitMarkerFile}"`,
|
|
111
|
+
'exit /b %errorlevel%',
|
|
112
|
+
'',
|
|
113
|
+
].join('\r\n');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The WMI-created process runs its command under `cmd /d /s /c "..."`; the
|
|
118
|
+
* redirection is part of that single /c string, so the extension does not need
|
|
119
|
+
* to hold output file descriptors (the child writes the file directly).
|
|
120
|
+
*
|
|
121
|
+
* `cmd /s` keeps the outermost quote pair: it strips the first and last quote
|
|
122
|
+
* of the /c argument, leaving `<body> 1>"<output>" 2>&1` for command parsing.
|
|
123
|
+
* A bare `exit N` in the user command therefore still reaches the epilogue
|
|
124
|
+
* through the runner's nested cmd.
|
|
125
|
+
*/
|
|
126
|
+
export function buildWindowsWmiCommandLine(body: string, outputAbsPath: string): string {
|
|
127
|
+
return `cmd.exe /d /s /c "${body} 1>"${outputAbsPath}" 2>&1"`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Body for a wrapped cmd task: one quoted token pointing at the runner file.
|
|
132
|
+
* The runner script carries the user command and the epilogue.
|
|
133
|
+
*/
|
|
134
|
+
export function buildWindowsRunnerBody(runnerScriptFile: string): string {
|
|
135
|
+
return `"${runnerScriptFile}"`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** True for dialects the wrapper understands; 'none' loses exact exit codes. */
|
|
139
|
+
export function wrapModeForDialect(dialect: ResolvedShellPolicy['dialect']): DetachedWrapMode {
|
|
140
|
+
if (dialect === 'posix' || dialect === 'bash') return 'posix';
|
|
141
|
+
if (dialect === 'cmd') return 'cmd';
|
|
142
|
+
return 'none';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Launch one detached task. Failures clean up every artifact before an error
|
|
147
|
+
* is thrown, so the caller's failure path never sees a half-written task.
|
|
148
|
+
*/
|
|
149
|
+
export async function spawnDetachedTask(
|
|
150
|
+
options: SpawnDetachedTaskOptions,
|
|
151
|
+
): Promise<SpawnDetachedTaskResult> {
|
|
152
|
+
const platform = options.platform ?? process.platform;
|
|
153
|
+
if (platform === 'win32') return spawnWindowsDetachedTask(options);
|
|
154
|
+
return spawnPosixDetachedTask(options);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Windows: everything goes through the WMI service so the task outlives its
|
|
159
|
+
* creating process. The runner script (with the exit-marker epilogue) and the
|
|
160
|
+
* one-shot WMI helper clean up on any launch failure.
|
|
161
|
+
*/
|
|
162
|
+
async function spawnWindowsDetachedTask(
|
|
163
|
+
options: SpawnDetachedTaskOptions,
|
|
164
|
+
): Promise<SpawnDetachedTaskResult> {
|
|
165
|
+
const id = options.id;
|
|
166
|
+
const taskDirAbs = options.taskDirAbs;
|
|
167
|
+
const outputAbsPath = join(taskDirAbs, `${id}.output`);
|
|
168
|
+
const exitMarkerFile = exitMarkerPath(taskDirAbs, id);
|
|
169
|
+
const wrapMode = wrapModeForDialect(options.policy.dialect);
|
|
170
|
+
|
|
171
|
+
let runnerScriptFile: string | undefined;
|
|
172
|
+
let commandLine: string;
|
|
173
|
+
if (wrapMode === 'none') {
|
|
174
|
+
// Unclassified user shell: launch it verbatim under cmd, without an exit
|
|
175
|
+
// marker; the redirection still writes the task output.
|
|
176
|
+
const args = [...options.policy.argvPrefix, options.command];
|
|
177
|
+
commandLine = buildWindowsWmiCommandLine(
|
|
178
|
+
`${quoteWinArg(options.policy.executable)} ${args.join(' ')}`,
|
|
179
|
+
outputAbsPath,
|
|
180
|
+
);
|
|
181
|
+
} else {
|
|
182
|
+
// 'cmd' (and defensively any wrapped dialect): the runner file carries the
|
|
183
|
+
// user command plus the exit-marker epilogue.
|
|
184
|
+
runnerScriptFile = runnerScriptPath(taskDirAbs, id);
|
|
185
|
+
await writeFileFsynced(
|
|
186
|
+
runnerScriptFile,
|
|
187
|
+
buildWindowsRunnerScript(options.command, exitMarkerFile, options.policy.executable),
|
|
188
|
+
);
|
|
189
|
+
commandLine = buildWindowsWmiCommandLine(
|
|
190
|
+
buildWindowsRunnerBody(runnerScriptFile),
|
|
191
|
+
outputAbsPath,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const sweepRunner = (): void => {
|
|
196
|
+
if (runnerScriptFile === undefined) return;
|
|
197
|
+
try {
|
|
198
|
+
unlinkSync(runnerScriptFile);
|
|
199
|
+
} catch {
|
|
200
|
+
// Best effort; the task dir is swept later if this fails.
|
|
201
|
+
}
|
|
202
|
+
runnerScriptFile = undefined;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
let pid: number;
|
|
206
|
+
try {
|
|
207
|
+
const result = await createProcessViaWmi({
|
|
208
|
+
commandLine,
|
|
209
|
+
currentDirectory: options.cwd,
|
|
210
|
+
env: options.env,
|
|
211
|
+
});
|
|
212
|
+
pid = result.pid;
|
|
213
|
+
} catch (error) {
|
|
214
|
+
sweepRunner();
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
|
218
|
+
sweepRunner();
|
|
219
|
+
throw new Error('detached task spawn failed before a valid pid was acquired');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
exitMarkerFile,
|
|
224
|
+
exitMarkerTrusted: wrapMode !== 'none',
|
|
225
|
+
runnerScriptFile,
|
|
226
|
+
pid,
|
|
227
|
+
posixProcessGroupId: undefined,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* POSIX: spawn detached with the output file opened directly. The child leads
|
|
233
|
+
* its own process group, so `-pgid` kills remain authoritative from any
|
|
234
|
+
* process (the recorded `pgid === pid` is the ownership contract).
|
|
235
|
+
*/
|
|
236
|
+
async function spawnPosixDetachedTask(
|
|
237
|
+
options: SpawnDetachedTaskOptions,
|
|
238
|
+
): Promise<SpawnDetachedTaskResult> {
|
|
239
|
+
const spawnFn = options.spawn ?? mapNodeSpawn;
|
|
240
|
+
const id = options.id;
|
|
241
|
+
const taskDirAbs = options.taskDirAbs;
|
|
242
|
+
const outputAbsPath = join(taskDirAbs, `${id}.output`);
|
|
243
|
+
const exitMarkerFile = exitMarkerPath(taskDirAbs, id);
|
|
244
|
+
const wrapMode = wrapModeForDialect(options.policy.dialect);
|
|
245
|
+
|
|
246
|
+
const invocation =
|
|
247
|
+
wrapMode === 'posix'
|
|
248
|
+
? {
|
|
249
|
+
shell: options.policy.executable,
|
|
250
|
+
args: [
|
|
251
|
+
...options.policy.argvPrefix,
|
|
252
|
+
buildPosixWrappedCommand(options.command, exitMarkerFile),
|
|
253
|
+
],
|
|
254
|
+
windowsVerbatimArguments: options.policy.windowsVerbatimArguments,
|
|
255
|
+
}
|
|
256
|
+
: {
|
|
257
|
+
// Unclassified user shell: launch unwrapped, without an exit marker.
|
|
258
|
+
shell: options.policy.executable,
|
|
259
|
+
args: [...options.policy.argvPrefix, options.command],
|
|
260
|
+
windowsVerbatimArguments: options.policy.windowsVerbatimArguments,
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// The child duplicates this descriptor; 'a' keeps both ends in append mode.
|
|
264
|
+
const outFd = openSync(outputAbsPath, 'a', 0o666);
|
|
265
|
+
const cleanup = (): void => {
|
|
266
|
+
try {
|
|
267
|
+
closeSync(outFd);
|
|
268
|
+
} catch {
|
|
269
|
+
// The descriptor may already be closed by a partial failure path.
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
let child: DetachedSpawnChild;
|
|
274
|
+
try {
|
|
275
|
+
child = spawnFn(invocation.shell, invocation.args, {
|
|
276
|
+
cwd: options.cwd,
|
|
277
|
+
detached: true,
|
|
278
|
+
stdio: ['ignore', outFd, outFd],
|
|
279
|
+
env: options.env,
|
|
280
|
+
windowsHide: true,
|
|
281
|
+
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
|
282
|
+
});
|
|
283
|
+
} catch (error) {
|
|
284
|
+
cleanup();
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
const pid = child.pid;
|
|
288
|
+
if (pid === undefined || !Number.isSafeInteger(pid) || pid <= 0) {
|
|
289
|
+
try {
|
|
290
|
+
child.kill('SIGKILL');
|
|
291
|
+
} catch {
|
|
292
|
+
// The launch error below remains authoritative; no pid was acquired.
|
|
293
|
+
}
|
|
294
|
+
cleanup();
|
|
295
|
+
throw new Error('detached task spawn failed before a valid pid was acquired');
|
|
296
|
+
}
|
|
297
|
+
child.unref?.();
|
|
298
|
+
// The child holds its own duplicated descriptor; release the parent's copy.
|
|
299
|
+
closeSync(outFd);
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
exitMarkerFile,
|
|
303
|
+
exitMarkerTrusted: wrapMode !== 'none',
|
|
304
|
+
pid,
|
|
305
|
+
posixProcessGroupId: pid,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Quotes one token for a raw CreateProcess command line (no shell here). */
|
|
310
|
+
function quoteWinArg(value: string): string {
|
|
311
|
+
if (!/[\s"]/.test(value)) return value;
|
|
312
|
+
return `"${value.replace(/"/g, '\\"')}"`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function mapNodeSpawn(command: string, args: string[], options: SpawnOptions): DetachedSpawnChild {
|
|
316
|
+
return nodeSpawn(command, args, options);
|
|
317
|
+
}
|