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
package/src/core/task-cleanup.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readdir, readFile, rm } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { TERMINAL_TASK_STATUS_VALUES } from './common.js';
|
|
4
|
+
import { EXIT_MARKER_SUFFIX, RUNNER_SCRIPT_SUFFIX } from './detached-spawn.js';
|
|
4
5
|
|
|
5
6
|
export type CleanupScope = 'one' | 'session' | 'all';
|
|
6
7
|
|
|
@@ -59,6 +60,11 @@ async function readTaskStatus(jsonPath: string): Promise<string | undefined> {
|
|
|
59
60
|
async function deleteTaskFiles(runDir: string, id: string): Promise<void> {
|
|
60
61
|
await rm(join(runDir, `${id}.json`), { force: true });
|
|
61
62
|
await rm(join(runDir, `${id}.output`), { force: true });
|
|
63
|
+
// Ownership artifacts of a settled task: the wrapper marker and the
|
|
64
|
+
// generated runner script. Both are dead weight once the record is
|
|
65
|
+
// terminal, and they keep the run dir from ever becoming prunable.
|
|
66
|
+
await rm(join(runDir, `${id}${EXIT_MARKER_SUFFIX}`), { force: true });
|
|
67
|
+
await rm(join(runDir, `${id}${RUNNER_SCRIPT_SUFFIX}`), { force: true });
|
|
62
68
|
}
|
|
63
69
|
|
|
64
70
|
async function removeEmptyRunDir(runDir: string): Promise<void> {
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { spawn as nodeSpawn, type SpawnOptions } from 'node:child_process';
|
|
2
2
|
import { win32 } from 'node:path';
|
|
3
3
|
|
|
4
|
-
export type WindowsKillPhase = 'terminate' | 'force';
|
|
5
|
-
|
|
6
4
|
export interface TaskkillOutcome {
|
|
7
5
|
readonly exitCode: number | null;
|
|
8
6
|
readonly signal: NodeJS.Signals | null;
|
|
@@ -117,11 +115,7 @@ function validatePid(pid: number): void {
|
|
|
117
115
|
}
|
|
118
116
|
}
|
|
119
117
|
|
|
120
|
-
|
|
121
|
-
if (phase !== 'terminate' && phase !== 'force') {
|
|
122
|
-
throw new Error(`Invalid Windows taskkill phase ${String(phase)}`);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
118
|
+
// (the two-phase 'terminate'/'force' split was removed: /F is the only kill)
|
|
125
119
|
|
|
126
120
|
function positiveFiniteInteger(value: number | undefined, fallback: number, label: string): number {
|
|
127
121
|
const candidate = value ?? fallback;
|
|
@@ -157,11 +151,9 @@ function defaultSpawn(
|
|
|
157
151
|
|
|
158
152
|
export function runWindowsTaskkill(
|
|
159
153
|
pid: number,
|
|
160
|
-
phase: WindowsKillPhase,
|
|
161
154
|
options: WindowsTaskkillOptions = {},
|
|
162
155
|
): Promise<TaskkillOutcome> {
|
|
163
156
|
validatePid(pid);
|
|
164
|
-
validatePhase(phase);
|
|
165
157
|
const env = options.env ?? process.env;
|
|
166
158
|
const taskkill = resolveTaskkillPath(env);
|
|
167
159
|
const timeoutMs = positiveFiniteInteger(options.timeoutMs, DEFAULT_TIMEOUT_MS, 'timeoutMs');
|
|
@@ -173,8 +165,9 @@ export function runWindowsTaskkill(
|
|
|
173
165
|
const spawn = options.spawn ?? defaultSpawn;
|
|
174
166
|
const abortSignal = options.signal;
|
|
175
167
|
|
|
176
|
-
|
|
177
|
-
|
|
168
|
+
// Single tree termination: console applications do not honor window-close
|
|
169
|
+
// termination, so forced termination is the only kill that works.
|
|
170
|
+
const args = ['/PID', String(pid), '/T', '/F'];
|
|
178
171
|
|
|
179
172
|
if (abortSignal?.aborted) {
|
|
180
173
|
const stdout = new BoundedCapture(maxCaptureBytes);
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Windows detach helper: the task's outer process is created by the WMI
|
|
6
|
+
* service (`Win32_Process.Create`), not by the extension process.
|
|
7
|
+
*
|
|
8
|
+
* Background-task survival on this platform requires the task to be outside
|
|
9
|
+
* every process job and console session that belongs to the Pi activation:
|
|
10
|
+
* children spawned directly by the Pi process die together with it (job and
|
|
11
|
+
* console-session teardown), no matter how they are detached. Processes
|
|
12
|
+
* created through WMI have `WmiPrvSE.exe` as their service-side parent and
|
|
13
|
+
* outlive the activating process, its console, and its SSH/terminal session
|
|
14
|
+
* entirely — verified end to end on Windows 11.
|
|
15
|
+
*
|
|
16
|
+
* The helper is a short-lived PowerShell one-shot: it performs exactly one
|
|
17
|
+
* `Win32_Process.Create` and prints the new pid. It is spawned directly by the
|
|
18
|
+
* extension and never survives the launch itself, so a dead activation can
|
|
19
|
+
* never leave a launch in flight (failures surface as launch errors).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface WmiHelperChild {
|
|
23
|
+
readonly pid?: number | undefined;
|
|
24
|
+
readonly stdout: {
|
|
25
|
+
on(event: 'data', listener: (data: Buffer | string) => void): unknown;
|
|
26
|
+
};
|
|
27
|
+
readonly stderr: {
|
|
28
|
+
on(event: 'data', listener: (data: Buffer | string) => void): unknown;
|
|
29
|
+
};
|
|
30
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
31
|
+
on(event: 'error', listener: (error: Error) => void): unknown;
|
|
32
|
+
on(
|
|
33
|
+
event: 'close',
|
|
34
|
+
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
|
|
35
|
+
): unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type WmiSpawnFn = (
|
|
39
|
+
file: string,
|
|
40
|
+
args: string[],
|
|
41
|
+
options: { env: NodeJS.ProcessEnv; windowsVerbatimArguments?: boolean },
|
|
42
|
+
) => WmiHelperChild;
|
|
43
|
+
|
|
44
|
+
export interface WindowsWmiSpawnOptions {
|
|
45
|
+
/** Full CreateProcess command line for the new process. */
|
|
46
|
+
readonly commandLine: string;
|
|
47
|
+
readonly currentDirectory: string;
|
|
48
|
+
readonly env?: NodeJS.ProcessEnv | undefined;
|
|
49
|
+
/** Test seam; defaults to PowerShell via SystemRoot. */
|
|
50
|
+
readonly spawn?: WmiSpawnFn | undefined;
|
|
51
|
+
readonly timeoutMs?: number | undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const WMI_CREATE_TIMEOUT_MS = 20_000;
|
|
55
|
+
|
|
56
|
+
const PS_HEADER = ['$ErrorActionPreference = "Stop"'].join('\n');
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Builds the one-shot PowerShell script: creates the process hidden, reports
|
|
60
|
+
* failures as `WMIERR <ReturnValue>` on stdout, otherwise prints the pid.
|
|
61
|
+
*/
|
|
62
|
+
export function buildWindowsWmiCreateScript(commandLine: string, currentDirectory: string): string {
|
|
63
|
+
return [
|
|
64
|
+
PS_HEADER,
|
|
65
|
+
`$r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{` +
|
|
66
|
+
`CommandLine = '${escapePowerShellSingleQuoted(commandLine)}';` +
|
|
67
|
+
`CurrentDirectory = '${escapePowerShellSingleQuoted(currentDirectory)}' }`,
|
|
68
|
+
'if ($r.ReturnValue -ne 0) { Write-Output ("WMIERR " + $r.ReturnValue); exit 1 }',
|
|
69
|
+
'Write-Output $r.ProcessId',
|
|
70
|
+
].join('\n');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function escapePowerShellSingleQuoted(value: string): string {
|
|
74
|
+
return value.replace(/'/g, "''");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function encodeWindowsWmiScript(script: string): string {
|
|
78
|
+
return Buffer.from(script, 'utf16le').toString('base64');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function powershellExecutable(env: NodeJS.ProcessEnv = process.env): string {
|
|
82
|
+
const windir = env.Windir ?? env.SYSTEMROOT ?? env.winDir ?? 'C:\\Windows';
|
|
83
|
+
return join(windir, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Parses `WMIERR N` / `<pid>` helper output into either a failure or a pid. */
|
|
87
|
+
export function parseWmiHelperOutput(stdout: string, stderr: string): { pid: number } {
|
|
88
|
+
const lines = stdout
|
|
89
|
+
.split(/\r?\n/)
|
|
90
|
+
.map((line) => line.trim())
|
|
91
|
+
.filter((line) => line.length > 0);
|
|
92
|
+
const errLine = lines.find((line) => line.startsWith('WMIERR'));
|
|
93
|
+
if (errLine !== undefined) {
|
|
94
|
+
const code = errLine.slice('WMIERR'.length).trim();
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Win32_Process.Create failed (ReturnValue ${code || 'unknown'})` +
|
|
97
|
+
(stderr.length > 0 ? `; helper output: ${stderr.trim()}` : ''),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const candidate = lines[lines.length - 1];
|
|
101
|
+
const pid = candidate === undefined ? Number.NaN : Number(candidate);
|
|
102
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Windows WMI spawn did not return a pid${stderr.length > 0 ? `; helper output: ${stderr.trim()}` : ''}`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return { pid };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function createProcessViaWmi(
|
|
111
|
+
options: WindowsWmiSpawnOptions,
|
|
112
|
+
): Promise<{ pid: number }> {
|
|
113
|
+
const spawn = options.spawn ?? defaultWmiSpawn;
|
|
114
|
+
const env = options.env ?? process.env;
|
|
115
|
+
const scriptFile = encodeWindowsWmiScript(
|
|
116
|
+
buildWindowsWmiCreateScript(options.commandLine, options.currentDirectory),
|
|
117
|
+
);
|
|
118
|
+
const timeoutMs = options.timeoutMs ?? WMI_CREATE_TIMEOUT_MS;
|
|
119
|
+
|
|
120
|
+
const child = spawn(
|
|
121
|
+
powershellExecutable(env),
|
|
122
|
+
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', scriptFile],
|
|
123
|
+
{
|
|
124
|
+
env,
|
|
125
|
+
windowsVerbatimArguments: false,
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const stdout: string[] = [];
|
|
130
|
+
const stderr: string[] = [];
|
|
131
|
+
child.stdout?.on('data', (chunk) => stdout.push(String(chunk)));
|
|
132
|
+
child.stderr?.on('data', (chunk) => stderr.push(String(chunk)));
|
|
133
|
+
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
const rejectOnce = (error: Error): void => {
|
|
136
|
+
if (settled) return;
|
|
137
|
+
settled = true;
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
reject(error);
|
|
140
|
+
};
|
|
141
|
+
let settled = false;
|
|
142
|
+
let timer: NodeJS.Timeout | undefined = setTimeout(() => {
|
|
143
|
+
timer = undefined;
|
|
144
|
+
rejectOnce(
|
|
145
|
+
new Error(
|
|
146
|
+
`Windows WMI spawn exceeded ${String(Math.round(timeoutMs / 1000))}s timeout; the helper was terminated`,
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
try {
|
|
150
|
+
child.kill('SIGKILL');
|
|
151
|
+
} catch {
|
|
152
|
+
// Rejection is authoritative; the helper may already have exited.
|
|
153
|
+
}
|
|
154
|
+
}, timeoutMs);
|
|
155
|
+
|
|
156
|
+
const close = (code: number | null): void => {
|
|
157
|
+
if (settled) return;
|
|
158
|
+
settled = true;
|
|
159
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
160
|
+
const detail =
|
|
161
|
+
(code === null ? 'exit signal' : `exit ${String(code)}`) + tailOutput(stderr.join(''));
|
|
162
|
+
if (code !== 0) {
|
|
163
|
+
reject(new Error(`Windows WMI helper failed (${detail})`));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
resolve(parseWmiHelperOutput(stdout.join(''), stderr.join('')));
|
|
168
|
+
} catch (error) {
|
|
169
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
child.on('error', (error: Error) =>
|
|
173
|
+
rejectOnce(new Error(`Windows WMI helper failed to start: ${error.message}`)),
|
|
174
|
+
);
|
|
175
|
+
child.on('close', (code: number | null) => close(code));
|
|
176
|
+
});
|
|
177
|
+
function tailOutput(stderrText: string): string {
|
|
178
|
+
const normalized = stderrText.trim();
|
|
179
|
+
return normalized.length === 0 ? '' : `; helper output: ${normalized}`;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function defaultWmiSpawn(
|
|
184
|
+
file: string,
|
|
185
|
+
args: string[],
|
|
186
|
+
options: { env: NodeJS.ProcessEnv; windowsVerbatimArguments?: boolean },
|
|
187
|
+
): WmiHelperChild {
|
|
188
|
+
return nodeSpawn(file, args, {
|
|
189
|
+
env: options.env,
|
|
190
|
+
windowsVerbatimArguments: false,
|
|
191
|
+
}) as unknown as WmiHelperChild;
|
|
192
|
+
}
|