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.
@@ -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
- function validatePhase(phase: WindowsKillPhase): void {
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
- const args = ['/PID', String(pid), '/T'];
177
- if (phase === 'force') args.push('/F');
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
+ }