@mjasnikovs/pi-task 0.42.9 → 0.42.10

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.
@@ -10,8 +10,11 @@ export interface Leftovers {
10
10
  * `graceMs` is POSIX's, between SIGTERM and SIGKILL. win32 has no grace to give:
11
11
  * `taskkill /F` returns once the tree is dead, and its port free (40 of 40 on the
12
12
  * windows runner).
13
+ *
14
+ * `find` is the token scan, injectable because losing a live leftover from it is
15
+ * what the reap has to survive — see the comment on the scan in `trackToken`.
13
16
  */
14
- export declare function trackLeftovers(platform: NodeJS.Platform, base: NodeJS.ProcessEnv, graceMs: number): Leftovers;
17
+ export declare function trackLeftovers(platform: NodeJS.Platform, base: NodeJS.ProcessEnv, graceMs: number, find?: (marker: string) => number[]): Leftovers;
15
18
  /**
16
19
  * A System32 executable by absolute path, so neither PATH nor the working directory
17
20
  * can supply another. `||`, not `??`: an empty SystemRoot would make it relative.
@@ -25,12 +25,16 @@ const USER_BASH_ENV = 'PI_TASK_USER_BASH_ENV';
25
25
  * `graceMs` is POSIX's, between SIGTERM and SIGKILL. win32 has no grace to give:
26
26
  * `taskkill /F` returns once the tree is dead, and its port free (40 of 40 on the
27
27
  * windows runner).
28
+ *
29
+ * `find` is the token scan, injectable because losing a live leftover from it is
30
+ * what the reap has to survive — see the comment on the scan in `trackToken`.
28
31
  */
29
- export function trackLeftovers(platform, base, graceMs) {
32
+ export function trackLeftovers(platform, base, graceMs, find) {
30
33
  if (platform === 'win32')
31
34
  return trackShells(base);
32
- if (platform === 'linux' || platform === 'darwin')
33
- return trackToken(platform, base, graceMs);
35
+ if (platform === 'linux' || platform === 'darwin') {
36
+ return trackToken(platform, base, graceMs, find);
37
+ }
34
38
  return { env: base, reap: () => Promise.resolve() };
35
39
  }
36
40
  /**
@@ -41,30 +45,46 @@ export function system32(...segments) {
41
45
  return path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', ...segments);
42
46
  }
43
47
  // ─── linux, darwin: the environment token ───────────────────────────────────
44
- function trackToken(platform, base, graceMs) {
48
+ function trackToken(platform, base, graceMs, scan) {
45
49
  const token = randomUUID();
46
50
  const marker = `${LEFTOVER_TOKEN_ENV}=${token}`;
47
- const find = () => platform === 'linux' ? linuxPidsWith(marker) : pidsInPsTable(darwinPsTable(), marker);
51
+ const defaultScan = (m) => platform === 'linux' ? linuxPidsWith(m) : pidsInPsTable(darwinPsTable(), m);
52
+ const find = () => (scan ?? defaultScan)(marker);
53
+ const endedOf = platform === 'linux' ? linuxEnded : darwinEnded;
48
54
  return {
49
55
  env: { ...base, [LEFTOVER_TOKEN_ENV]: token },
50
56
  reap: () => new Promise(resolve => {
51
57
  const started = performance.now();
52
58
  let killed = false;
53
- signalEach(find(), 'SIGTERM');
54
- // Found again on every pass, never remembered: a pid that died in
55
- // the grace period may already be someone else's. Each pass waits
56
- // as long as the scan before it took, so the wait costs half a core
57
- // at most and no invented interval.
59
+ // Discovery stays by token, so a pid recycled mid-reap is never
60
+ // signalled. Liveness cannot: a dying process releases its memory
61
+ // and with it the token while it still holds its ports, so the
62
+ // scan reads it as gone about 9 times in 10. Each pid found is
63
+ // pinned to its start time and followed in the process table until
64
+ // that entry is reaped.
65
+ const held = new Map();
66
+ const follow = () => {
67
+ for (const pid of find())
68
+ if (!held.has(pid))
69
+ held.set(pid, hold(platform, pid));
70
+ for (const [pid, h] of held)
71
+ if (endedOf(h))
72
+ held.delete(pid);
73
+ };
74
+ follow();
75
+ signalEach([...held.keys()], 'SIGTERM');
76
+ // Each pass waits as long as the scan before it took, so the wait
77
+ // costs half a core at most and no invented interval.
58
78
  const poll = () => {
59
79
  const scanStart = performance.now();
60
- const left = find();
80
+ follow();
61
81
  const waited = performance.now() - started;
62
82
  // A process SIGKILL cannot end (uninterruptible sleep) gets one
63
83
  // more grace, then the run goes on without it.
64
- if (left.length === 0 || waited >= 2 * graceMs)
84
+ if (held.size === 0 || waited >= 2 * graceMs)
65
85
  return resolve();
66
86
  if (!killed && waited >= graceMs) {
67
- signalEach(left, 'SIGKILL');
87
+ signalEach([...held.keys()], 'SIGKILL');
68
88
  killed = true;
69
89
  }
70
90
  setTimeout(poll, performance.now() - scanStart).unref();
@@ -73,6 +93,55 @@ function trackToken(platform, base, graceMs) {
73
93
  })
74
94
  };
75
95
  }
96
+ function hold(platform, pid) {
97
+ const startedAt = platform === 'linux' ? linuxStartTime(pid) : darwinField(pid, 'lstart=');
98
+ return startedAt === undefined ? { pid } : { pid, startedAt };
99
+ }
100
+ /**
101
+ * Whether the process `held` names has ended, zombies included: a zombie has already
102
+ * released its ports and only waits to be reaped. A pid whose start time no longer
103
+ * matches belongs to someone else, so the one we held is gone.
104
+ */
105
+ function linuxEnded(held) {
106
+ const rest = linuxStatAfterName(held.pid);
107
+ if (rest === null)
108
+ return true;
109
+ if (held.startedAt !== undefined && startTimeIn(rest) !== held.startedAt)
110
+ return true;
111
+ return /^[ZX]/.test(rest);
112
+ }
113
+ function darwinEnded(held) {
114
+ const state = darwinField(held.pid, 'state=');
115
+ if (state === undefined)
116
+ return true;
117
+ if (held.startedAt !== undefined && darwinField(held.pid, 'lstart=') !== held.startedAt) {
118
+ return true;
119
+ }
120
+ return state.startsWith('Z');
121
+ }
122
+ /** `/proc/<pid>/stat` from the state char on: the name before it can hold ') Z' itself. */
123
+ function linuxStatAfterName(pid) {
124
+ try {
125
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
126
+ return stat.slice(stat.lastIndexOf(')') + 2);
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ }
132
+ /** starttime is stat field 22, and `rest` begins at field 3. */
133
+ function startTimeIn(rest) {
134
+ return rest.split(' ')[19];
135
+ }
136
+ function linuxStartTime(pid) {
137
+ const rest = linuxStatAfterName(pid);
138
+ return rest === null ? undefined : startTimeIn(rest);
139
+ }
140
+ function darwinField(pid, field) {
141
+ const r = spawnSync('/bin/ps', ['-o', field, '-p', String(pid)], { encoding: 'utf8' });
142
+ const out = r.stdout?.trim();
143
+ return out ? out : undefined;
144
+ }
76
145
  /** Pids whose environment holds `marker`, read from `<procRoot>/<pid>/environ`. */
77
146
  export function linuxPidsWith(marker, procRoot = '/proc') {
78
147
  const inner = Buffer.from(`\0${marker}\0`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.42.9",
3
+ "version": "0.42.10",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",