dsh-autorestart 0.6.1 → 0.7.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/README.md +457 -41
- package/assets/screenshot-1.png +0 -0
- package/assets/screenshot-2.png +0 -0
- package/assets/screenshot-3.png +0 -0
- package/assets/screenshot-4.png +0 -0
- package/assets/screenshot-5.png +0 -0
- package/client.js +5973 -3222
- package/cordis.patch.yml +55 -9
- package/docs/README.zh.md +305 -27
- package/index.js +2522 -127
- package/lib/access.js +212 -0
- package/lib/activity.js +124 -0
- package/lib/autostart-advice.js +198 -0
- package/lib/backup.js +131 -4
- package/lib/blockers.js +228 -0
- package/lib/checkpoint.js +147 -0
- package/lib/diagnostics.js +180 -0
- package/lib/health.js +264 -0
- package/lib/host-kind.js +145 -0
- package/lib/lifecycle.js +17 -4
- package/lib/node-runtime.js +144 -0
- package/lib/queued.js +146 -0
- package/lib/restart-verdict.js +327 -0
- package/lib/restart.js +823 -56
- package/lib/sleep.js +46 -49
- package/lib/startup-mode.js +491 -0
- package/lib/supervisor-config.js +735 -0
- package/lib/supervisor-install.js +557 -0
- package/lib/supervisor.js +1276 -0
- package/locale/en.json +247 -60
- package/locale/zh.json +247 -60
- package/package.json +7 -2
- package/screenshots.json +6 -4
- package/scripts/emergency-disable.cmd +27 -0
- package/scripts/emergency-toggle.ps1 +302 -0
- package/scripts/run-tests.ps1 +46 -0
- package/supervisor/supervisor.js +884 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-autorestart — THE DIAGNOSTICS BEHIND "REPORT A PROBLEM".
|
|
3
|
+
*
|
|
4
|
+
* When something goes wrong, the user should be able to hand us the facts in
|
|
5
|
+
* one click — without writing anything down by hand and without leaking
|
|
6
|
+
* anything private.
|
|
7
|
+
*
|
|
8
|
+
* There is exactly ONE generator (`buildDiagnostics`): the host builds the
|
|
9
|
+
* text, the browser copies it. Two generators would drift, and a drifted
|
|
10
|
+
* diagnostic is worse than none.
|
|
11
|
+
*
|
|
12
|
+
* WHAT IS IN IT: the plugin version, the platform, whether this is the
|
|
13
|
+
* desktop client, how the host was started (the mode AND the evidence the
|
|
14
|
+
* verdict rests on), who supervises it, the restart mode, and the verdict of
|
|
15
|
+
* the last restart (with the checks that failed).
|
|
16
|
+
*
|
|
17
|
+
* WHAT IS NEVER IN IT: the access token, a URL that carries a token, or any
|
|
18
|
+
* secret. The generator only copies the fields it knows, so a token cannot
|
|
19
|
+
* leak through a field nobody thought about — and everything that IS copied
|
|
20
|
+
* goes through the redaction again.
|
|
21
|
+
*
|
|
22
|
+
* ESM only, Node builtins only.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The mailbox that works when GitHub does not. */
|
|
26
|
+
export const SUPPORT_EMAIL = '252170229@qq.com'
|
|
27
|
+
|
|
28
|
+
/** Where the "new issue" page lives. */
|
|
29
|
+
export const ISSUE_URL_BASE = 'https://github.com/Eason-22/dsh-autorestart/issues/new'
|
|
30
|
+
|
|
31
|
+
/** The body of a pre-filled issue must stay short: URLs have a length limit. */
|
|
32
|
+
export const DIAGNOSTIC_BODY_LIMIT = 800
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The patterns that must never survive into a diagnostic.
|
|
36
|
+
*
|
|
37
|
+
* They cover the access token of the instance, the query parameters that
|
|
38
|
+
* carry it, and the usual "key: value" secrets.
|
|
39
|
+
*/
|
|
40
|
+
const SECRET_PATTERNS = Object.freeze([
|
|
41
|
+
/([?&](?:token|access[_-]?token|api[_-]?key|apikey|secret|password|auth|authorization)=)[^&\s"'<>]+/gi,
|
|
42
|
+
/("|')?(?:token|access[_-]?token|api[_-]?key|apikey|secret|password|authorization)("|')?\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;}\]]+)/gi,
|
|
43
|
+
/\bBearer\s+[A-Za-z0-9._~+/-]{8,}=*/gi,
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Remove anything that looks like a secret from a piece of text.
|
|
48
|
+
*
|
|
49
|
+
* @param {unknown} value
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
export function redactSecrets(value) {
|
|
53
|
+
let text = typeof value === 'string' ? value : String(value ?? '')
|
|
54
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
55
|
+
text = text.replace(pattern, '[redacted]')
|
|
56
|
+
}
|
|
57
|
+
return text
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Shorten a path: the last four segments are kept (the user name may stay),
|
|
62
|
+
* everything before them is cut.
|
|
63
|
+
*
|
|
64
|
+
* @param {unknown} value
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
export function truncatePath(value) {
|
|
68
|
+
const text = typeof value === 'string' ? value.trim() : ''
|
|
69
|
+
if (text === '') return ''
|
|
70
|
+
const parts = text.split(/[\\/]+/).filter((part) => part !== '')
|
|
71
|
+
if (parts.length <= 4) return text
|
|
72
|
+
return '…/' + parts.slice(-4).join('/')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Cut a text to a length limit (the issue body has to stay short).
|
|
77
|
+
*
|
|
78
|
+
* @param {string} text
|
|
79
|
+
* @param {number} [limit]
|
|
80
|
+
* @returns {string}
|
|
81
|
+
*/
|
|
82
|
+
export function truncateBody(text, limit = DIAGNOSTIC_BODY_LIMIT) {
|
|
83
|
+
const source = typeof text === 'string' ? text : ''
|
|
84
|
+
const max = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Math.trunc(Number(limit)) : DIAGNOSTIC_BODY_LIMIT
|
|
85
|
+
if (source.length <= max) return source
|
|
86
|
+
return source.slice(0, Math.max(0, max - 1)) + '…'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The pre-filled "new issue" URL.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} base
|
|
93
|
+
* @param {string} title
|
|
94
|
+
* @param {string} body
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
export function buildIssueUrl(base, title, body) {
|
|
98
|
+
const root = typeof base === 'string' && base.trim() !== '' ? base.trim() : ISSUE_URL_BASE
|
|
99
|
+
const safeTitle = truncateBody(redactSecrets(title), 200)
|
|
100
|
+
const safeBody = truncateBody(redactSecrets(body), DIAGNOSTIC_BODY_LIMIT)
|
|
101
|
+
return root + '?title=' + encodeURIComponent(safeTitle) + '&body=' + encodeURIComponent(safeBody)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The `mailto:` link (the fallback when there is no GitHub account).
|
|
106
|
+
*
|
|
107
|
+
* @param {string} email
|
|
108
|
+
* @param {string} subject
|
|
109
|
+
* @param {string} body
|
|
110
|
+
* @returns {string}
|
|
111
|
+
*/
|
|
112
|
+
export function buildMailtoUrl(email, subject, body) {
|
|
113
|
+
const to = typeof email === 'string' && email.trim() !== '' ? email.trim() : SUPPORT_EMAIL
|
|
114
|
+
const safeSubject = truncateBody(redactSecrets(subject), 200)
|
|
115
|
+
const safeBody = truncateBody(redactSecrets(body), DIAGNOSTIC_BODY_LIMIT)
|
|
116
|
+
return 'mailto:' + to + '?subject=' + encodeURIComponent(safeSubject) + '&body=' + encodeURIComponent(safeBody)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Read one field out of a payload, without ever throwing.
|
|
121
|
+
*
|
|
122
|
+
* @param {any} source
|
|
123
|
+
* @param {string} key
|
|
124
|
+
* @param {any} [fallback]
|
|
125
|
+
* @returns {any}
|
|
126
|
+
*/
|
|
127
|
+
function field(source, key, fallback = '') {
|
|
128
|
+
if (!source || typeof source !== 'object') return fallback
|
|
129
|
+
const value = source[key]
|
|
130
|
+
return value === undefined || value === null ? fallback : value
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The diagnostic text of this host.
|
|
135
|
+
*
|
|
136
|
+
* It is a PURE function of the payload: the same facts always produce the
|
|
137
|
+
* same text, and nothing outside the fields listed below can ever end up in
|
|
138
|
+
* it.
|
|
139
|
+
*
|
|
140
|
+
* @param {{
|
|
141
|
+
* version?: string,
|
|
142
|
+
* platform?: string,
|
|
143
|
+
* hostKind?: string,
|
|
144
|
+
* isDesktopApp?: boolean,
|
|
145
|
+
* startup?: { mode?: string, kind?: string, evidence?: string },
|
|
146
|
+
* supervisor?: { mode?: string, kind?: string },
|
|
147
|
+
* restartMode?: string,
|
|
148
|
+
* lastRestart?: { result?: string, failedChecks?: string[] },
|
|
149
|
+
* stateDir?: string,
|
|
150
|
+
* }} [payload]
|
|
151
|
+
* @returns {string}
|
|
152
|
+
*/
|
|
153
|
+
export function buildDiagnostics(payload = {}) {
|
|
154
|
+
const source = payload && typeof payload === 'object' ? payload : {}
|
|
155
|
+
const startup = field(source, 'startup', {})
|
|
156
|
+
const supervisor = field(source, 'supervisor', {})
|
|
157
|
+
const lastRestart = field(source, 'lastRestart', {})
|
|
158
|
+
const failedChecks = Array.isArray(field(lastRestart, 'failedChecks', []))
|
|
159
|
+
? field(lastRestart, 'failedChecks', []).map((check) => redactSecrets(String(check)))
|
|
160
|
+
: []
|
|
161
|
+
|
|
162
|
+
const lines = [
|
|
163
|
+
'--- dsh-autorestart diagnostics ---',
|
|
164
|
+
'plugin version: ' + redactSecrets(field(source, 'version', 'unknown')),
|
|
165
|
+
'platform: ' + redactSecrets(field(source, 'platform', 'unknown')),
|
|
166
|
+
'host kind: ' + redactSecrets(field(source, 'hostKind', 'unknown')),
|
|
167
|
+
'desktop client: ' + (field(source, 'isDesktopApp', false) === true ? 'yes' : 'no'),
|
|
168
|
+
'startup mode: ' + redactSecrets(field(startup, 'mode', 'unknown')),
|
|
169
|
+
'startup evidence: ' + redactSecrets(field(startup, 'evidence', 'unknown')),
|
|
170
|
+
'supervisor mode: ' + redactSecrets(field(supervisor, 'mode', 'unknown')),
|
|
171
|
+
'supervisor kind: ' + redactSecrets(field(supervisor, 'kind', 'unknown')),
|
|
172
|
+
'restart mode: ' + redactSecrets(field(source, 'restartMode', 'unknown')),
|
|
173
|
+
'last restart result: ' + redactSecrets(field(lastRestart, 'result', 'unknown')),
|
|
174
|
+
'last restart failed checks: ' + (failedChecks.length > 0 ? failedChecks.join(', ') : 'none'),
|
|
175
|
+
]
|
|
176
|
+
const stateDir = truncatePath(field(source, 'stateDir', ''))
|
|
177
|
+
if (stateDir !== '') lines.push('dsh home: ' + redactSecrets(stateDir))
|
|
178
|
+
lines.push('--- end ---')
|
|
179
|
+
return lines.join('\n')
|
|
180
|
+
}
|
package/lib/health.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-autorestart — "is the new host really up?".
|
|
3
|
+
*
|
|
4
|
+
* A restart is only successful when the NEW instance answers its health route.
|
|
5
|
+
* The old instance's answer proves nothing: the old instance may still be
|
|
6
|
+
* running (the restart command did nothing), or it may answer on a port the
|
|
7
|
+
* new instance does not use (the desktop app listens on a random port).
|
|
8
|
+
*
|
|
9
|
+
* So the confirmation is built from two facts:
|
|
10
|
+
*
|
|
11
|
+
* 1. the INSTANCE RECORD (lib/access.js) of the new instance — it carries a
|
|
12
|
+
* boot id that is different from the one of the instance that is being
|
|
13
|
+
* replaced, and the port the new instance really listens on;
|
|
14
|
+
* 2. the health route of THAT port.
|
|
15
|
+
*
|
|
16
|
+
* The wait is bounded: when the new instance does not answer in time the
|
|
17
|
+
* caller gets a REASON, never a silent success.
|
|
18
|
+
*
|
|
19
|
+
* ESM only, Node builtins only. No DSH / npm imports.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import http from 'node:http';
|
|
23
|
+
import { URL } from 'node:url';
|
|
24
|
+
|
|
25
|
+
import { readAccessRecordFile } from './access.js';
|
|
26
|
+
|
|
27
|
+
/** How long one health request may take before it counts as "no answer". */
|
|
28
|
+
export const DEFAULT_HEALTH_TIMEOUT_MS = 5000;
|
|
29
|
+
|
|
30
|
+
/** How long the new instance may take to come up at all. */
|
|
31
|
+
export const DEFAULT_INSTANCE_WAIT_MS = 90000;
|
|
32
|
+
|
|
33
|
+
/** How often the instance record is read while waiting. */
|
|
34
|
+
export const DEFAULT_INSTANCE_POLL_MS = 1000;
|
|
35
|
+
|
|
36
|
+
/** @param {unknown} value */
|
|
37
|
+
function errorText(value) {
|
|
38
|
+
if (value && typeof value.message === 'string' && value.message) return value.message;
|
|
39
|
+
return String(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Sleep for a while. Never rejects.
|
|
44
|
+
*
|
|
45
|
+
* @param {number} ms
|
|
46
|
+
* @returns {Promise<void>}
|
|
47
|
+
*/
|
|
48
|
+
function sleep(ms) {
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
const timer = setTimeout(resolve, Math.max(0, Number(ms) || 0));
|
|
51
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* One plain HTTP GET, answered with the parsed JSON body.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} url
|
|
59
|
+
* @param {number} timeoutMs
|
|
60
|
+
* @returns {Promise<{ ok: boolean, status: number, body: any, error: string|null }>}
|
|
61
|
+
*/
|
|
62
|
+
function httpGetJson(url, timeoutMs) {
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
let settled = false;
|
|
65
|
+
const finish = (result) => {
|
|
66
|
+
if (settled) return;
|
|
67
|
+
settled = true;
|
|
68
|
+
resolve(result);
|
|
69
|
+
};
|
|
70
|
+
let request;
|
|
71
|
+
try {
|
|
72
|
+
request = http.get(url, { timeout: timeoutMs }, (response) => {
|
|
73
|
+
const chunks = [];
|
|
74
|
+
response.on('data', (chunk) => chunks.push(chunk));
|
|
75
|
+
response.on('end', () => {
|
|
76
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
77
|
+
let body = null;
|
|
78
|
+
try {
|
|
79
|
+
body = JSON.parse(text);
|
|
80
|
+
} catch {
|
|
81
|
+
body = null;
|
|
82
|
+
}
|
|
83
|
+
const status = Number(response.statusCode) || 0;
|
|
84
|
+
finish({
|
|
85
|
+
ok: status >= 200 && status < 300 && body !== null && body.ok === true,
|
|
86
|
+
status,
|
|
87
|
+
body,
|
|
88
|
+
error: status >= 200 && status < 300
|
|
89
|
+
? (body !== null && body.ok === true ? null : 'the health route did not answer ok')
|
|
90
|
+
: 'the health route answered with status ' + status,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
response.on('error', (error) => {
|
|
94
|
+
finish({ ok: false, status: 0, body: null, error: errorText(error) });
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
} catch (error) {
|
|
98
|
+
finish({ ok: false, status: 0, body: null, error: errorText(error) });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
request.on('timeout', () => {
|
|
102
|
+
try {
|
|
103
|
+
request.destroy();
|
|
104
|
+
} catch {
|
|
105
|
+
/* nothing else to do */
|
|
106
|
+
}
|
|
107
|
+
finish({ ok: false, status: 0, body: null, error: 'the health request timed out' });
|
|
108
|
+
});
|
|
109
|
+
request.on('error', (error) => {
|
|
110
|
+
finish({ ok: false, status: 0, body: null, error: errorText(error) });
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Ask one health route whether the host behind it is alive.
|
|
117
|
+
*
|
|
118
|
+
* @param {{ url?: string, timeoutMs?: number, requestImpl?: Function }} [options]
|
|
119
|
+
* @returns {Promise<{ ok: boolean, status: number, error: string|null }>}
|
|
120
|
+
*/
|
|
121
|
+
export function probeHealth({ url = '', timeoutMs = DEFAULT_HEALTH_TIMEOUT_MS, requestImpl = null } = {}) {
|
|
122
|
+
const target = typeof url === 'string' ? url.trim() : '';
|
|
123
|
+
if (target === '') {
|
|
124
|
+
return Promise.resolve({ ok: false, status: 0, error: 'probeHealth requires a url' });
|
|
125
|
+
}
|
|
126
|
+
const budget = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0
|
|
127
|
+
? Math.trunc(Number(timeoutMs))
|
|
128
|
+
: DEFAULT_HEALTH_TIMEOUT_MS;
|
|
129
|
+
const request = typeof requestImpl === 'function' ? requestImpl : httpGetJson;
|
|
130
|
+
return new Promise((resolve) => {
|
|
131
|
+
let settled = false;
|
|
132
|
+
const finish = (result) => {
|
|
133
|
+
if (settled) return;
|
|
134
|
+
settled = true;
|
|
135
|
+
resolve(result);
|
|
136
|
+
};
|
|
137
|
+
const timer = setTimeout(() => {
|
|
138
|
+
finish({ ok: false, status: 0, error: 'the health request timed out after ' + budget + 'ms' });
|
|
139
|
+
}, budget);
|
|
140
|
+
Promise.resolve()
|
|
141
|
+
.then(() => request(target, budget))
|
|
142
|
+
.then((response) => {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
const status = Number(response && response.status) || 0;
|
|
145
|
+
if (response && response.ok === true) {
|
|
146
|
+
finish({ ok: true, status: status || 200, error: null });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
finish({
|
|
150
|
+
ok: false,
|
|
151
|
+
status,
|
|
152
|
+
error: (response && response.error) || 'the health route did not answer ok',
|
|
153
|
+
});
|
|
154
|
+
})
|
|
155
|
+
.catch((error) => {
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
finish({ ok: false, status: 0, error: errorText(error) });
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The health URL of one port on the loopback interface.
|
|
164
|
+
*
|
|
165
|
+
* @param {number|string} port
|
|
166
|
+
* @param {string} healthPath
|
|
167
|
+
* @returns {string}
|
|
168
|
+
*/
|
|
169
|
+
export function healthUrlForPort(port, healthPath) {
|
|
170
|
+
const number = Number(port);
|
|
171
|
+
if (!Number.isFinite(number) || number <= 0) return '';
|
|
172
|
+
const path = typeof healthPath === 'string' && healthPath.startsWith('/')
|
|
173
|
+
? healthPath
|
|
174
|
+
: '/__dsh_autorestart/health';
|
|
175
|
+
return 'http://127.0.0.1:' + Math.trunc(number) + path;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Wait until the instance that replaced `expectedBootId` answers its health
|
|
180
|
+
* route.
|
|
181
|
+
*
|
|
182
|
+
* The instance record is the only source of the new port: the desktop app
|
|
183
|
+
* listens on a random port, so the port of the old instance is meaningless.
|
|
184
|
+
*
|
|
185
|
+
* @param {{
|
|
186
|
+
* accessFile?: string,
|
|
187
|
+
* expectedBootId?: string|null,
|
|
188
|
+
* healthPath?: string,
|
|
189
|
+
* timeoutMs?: number,
|
|
190
|
+
* intervalMs?: number,
|
|
191
|
+
* probe?: Function,
|
|
192
|
+
* readRecord?: Function,
|
|
193
|
+
* now?: Function,
|
|
194
|
+
* sleep?: Function,
|
|
195
|
+
* }} [options]
|
|
196
|
+
* @returns {Promise<{ ok: boolean, bootId: string|null, port: number|null, url: string|null, reason: string }>}
|
|
197
|
+
*/
|
|
198
|
+
export async function waitForHealthyInstance({
|
|
199
|
+
accessFile = '',
|
|
200
|
+
expectedBootId = null,
|
|
201
|
+
healthPath = '/__dsh_autorestart/health',
|
|
202
|
+
timeoutMs = DEFAULT_INSTANCE_WAIT_MS,
|
|
203
|
+
intervalMs = DEFAULT_INSTANCE_POLL_MS,
|
|
204
|
+
probe = probeHealth,
|
|
205
|
+
readRecord = readAccessRecordFile,
|
|
206
|
+
now = () => Date.now(),
|
|
207
|
+
sleep: sleepFn = sleep,
|
|
208
|
+
} = {}) {
|
|
209
|
+
const file = typeof accessFile === 'string' ? accessFile.trim() : '';
|
|
210
|
+
const budget = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0
|
|
211
|
+
? Math.trunc(Number(timeoutMs))
|
|
212
|
+
: DEFAULT_INSTANCE_WAIT_MS;
|
|
213
|
+
const step = Number.isFinite(Number(intervalMs)) && Number(intervalMs) > 0
|
|
214
|
+
? Math.trunc(Number(intervalMs))
|
|
215
|
+
: DEFAULT_INSTANCE_POLL_MS;
|
|
216
|
+
const expected = typeof expectedBootId === 'string' && expectedBootId !== '' ? expectedBootId : null;
|
|
217
|
+
const deadline = now() + budget;
|
|
218
|
+
let lastReason = 'the new instance did not come up in time';
|
|
219
|
+
|
|
220
|
+
if (file === '') {
|
|
221
|
+
return { ok: false, bootId: null, port: null, url: null, reason: 'waitForHealthyInstance requires the instance record' };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
while (now() < deadline) {
|
|
225
|
+
const read = readRecord(file);
|
|
226
|
+
const record = read && read.ok === true ? read.record : null;
|
|
227
|
+
const bootId = record && typeof record.bootId === 'string' ? record.bootId : null;
|
|
228
|
+
const port = record && Number.isFinite(Number(record.port)) ? Number(record.port) : null;
|
|
229
|
+
const isNewInstance = bootId !== null && (expected === null || bootId !== expected);
|
|
230
|
+
if (isNewInstance && port !== null && port > 0) {
|
|
231
|
+
const url = healthUrlForPort(port, healthPath);
|
|
232
|
+
const answer = await probe({ url, timeoutMs: DEFAULT_HEALTH_TIMEOUT_MS });
|
|
233
|
+
if (answer && answer.ok === true) {
|
|
234
|
+
return { ok: true, bootId, port, url, reason: 'the new instance answered its health route' };
|
|
235
|
+
}
|
|
236
|
+
lastReason = (answer && answer.error) || 'the new instance did not answer its health route';
|
|
237
|
+
} else if (record === null) {
|
|
238
|
+
lastReason = 'the instance record is not readable yet';
|
|
239
|
+
} else if (!isNewInstance) {
|
|
240
|
+
lastReason = 'the instance that answers is still the one that is being replaced';
|
|
241
|
+
} else {
|
|
242
|
+
lastReason = 'the new instance did not publish its port yet';
|
|
243
|
+
}
|
|
244
|
+
await sleepFn(step);
|
|
245
|
+
}
|
|
246
|
+
return { ok: false, bootId: null, port: null, url: null, reason: lastReason };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The token of an access URL, or null.
|
|
251
|
+
*
|
|
252
|
+
* @param {string} url
|
|
253
|
+
* @returns {string|null}
|
|
254
|
+
*/
|
|
255
|
+
export function tokenOf(url) {
|
|
256
|
+
const text = typeof url === 'string' ? url.trim() : '';
|
|
257
|
+
if (text === '') return null;
|
|
258
|
+
try {
|
|
259
|
+
const parsed = new URL(text);
|
|
260
|
+
return parsed.searchParams.get('token');
|
|
261
|
+
} catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
}
|
package/lib/host-kind.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-autorestart — WHICH MACHINE IS THIS?
|
|
3
|
+
*
|
|
4
|
+
* The plugin has to know whether it runs on Windows, on a real Linux, on a
|
|
5
|
+
* macOS or INSIDE WSL (the Linux of Windows). The answer changes what may be
|
|
6
|
+
* done:
|
|
7
|
+
*
|
|
8
|
+
* - the graceful shutdown must reach the PHYSICAL machine (in WSL, shutting
|
|
9
|
+
* the distro down only stops the subsystem);
|
|
10
|
+
* - the "start at login" of the supervisor is a different thing in WSL
|
|
11
|
+
* (a systemd user unit + `loginctl enable-linger`, or a Windows-side
|
|
12
|
+
* task) than on a real Linux;
|
|
13
|
+
* - inside WSL there is NO Docker/Compose answer: the orchestrator is
|
|
14
|
+
* Windows itself.
|
|
15
|
+
*
|
|
16
|
+
* So the detection lives HERE, once, and both halves of the plugin use it.
|
|
17
|
+
*
|
|
18
|
+
* Everything is dependency-injected (the platform, the environment, the file
|
|
19
|
+
* probes), so the whole matrix can be tested without a machine.
|
|
20
|
+
*
|
|
21
|
+
* ESM only, Node builtins only.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs'
|
|
25
|
+
|
|
26
|
+
/** The Windows shutdown binary as it is reachable from WSL. */
|
|
27
|
+
export const WSL_SHUTDOWN_EXE = '/mnt/c/Windows/System32/shutdown.exe'
|
|
28
|
+
|
|
29
|
+
/** The file that tells a WSL kernel from a real Linux kernel. */
|
|
30
|
+
export const WSL_PROC_VERSION = '/proc/version'
|
|
31
|
+
|
|
32
|
+
/** The kinds of host the plugin knows. */
|
|
33
|
+
export const HOST_KINDS = Object.freeze({
|
|
34
|
+
WINDOWS: 'windows',
|
|
35
|
+
WSL: 'wsl',
|
|
36
|
+
LINUX: 'linux',
|
|
37
|
+
MACOS: 'macos',
|
|
38
|
+
UNSUPPORTED: 'unsupported',
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
/** @param {unknown} value */
|
|
42
|
+
function nonEmptyString(value) {
|
|
43
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : ''
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The evidence that this Linux is really a WSL distribution.
|
|
48
|
+
*
|
|
49
|
+
* Any ONE of them is enough:
|
|
50
|
+
* 1. `WSL_DISTRO_NAME` is set (WSL exports it into every process),
|
|
51
|
+
* 2. `WSL_INTEROP` is set (WSL exports it into every process),
|
|
52
|
+
* 3. `/proc/version` mentions "microsoft" (the WSL kernel says so),
|
|
53
|
+
* 4. the Windows shutdown binary is reachable under `/mnt/c`.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} env
|
|
56
|
+
* @param {(file: string) => string} readFile
|
|
57
|
+
* @param {(file: string) => boolean} exists
|
|
58
|
+
* @returns {string|null} the name of the evidence, or null
|
|
59
|
+
*/
|
|
60
|
+
export function detectWslEvidence(env, readFile, exists) {
|
|
61
|
+
const environ = env && typeof env === 'object' ? env : {}
|
|
62
|
+
if (nonEmptyString(environ.WSL_DISTRO_NAME) !== '') return 'WSL_DISTRO_NAME'
|
|
63
|
+
if (nonEmptyString(environ.WSL_INTEROP) !== '') return 'WSL_INTEROP'
|
|
64
|
+
let version = ''
|
|
65
|
+
try {
|
|
66
|
+
version = String(readFile(WSL_PROC_VERSION) || '')
|
|
67
|
+
} catch {
|
|
68
|
+
version = ''
|
|
69
|
+
}
|
|
70
|
+
if (version.toLowerCase().includes('microsoft')) return WSL_PROC_VERSION
|
|
71
|
+
let found = false
|
|
72
|
+
try {
|
|
73
|
+
found = exists(WSL_SHUTDOWN_EXE) === true
|
|
74
|
+
} catch {
|
|
75
|
+
found = false
|
|
76
|
+
}
|
|
77
|
+
if (found) return WSL_SHUTDOWN_EXE
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Which machine is this?
|
|
83
|
+
*
|
|
84
|
+
* `suspect` is true when the ONLY evidence is the mounted Windows drive: a
|
|
85
|
+
* real Linux with a manually mounted C: drive looks exactly the same. The
|
|
86
|
+
* plugin says so instead of pretending to be sure — and it does NOT change
|
|
87
|
+
* any system configuration on such a guess.
|
|
88
|
+
*
|
|
89
|
+
* @param {{
|
|
90
|
+
* platform?: string,
|
|
91
|
+
* env?: object,
|
|
92
|
+
* readFile?: (file: string) => string,
|
|
93
|
+
* exists?: (file: string) => boolean,
|
|
94
|
+
* }} [options]
|
|
95
|
+
* @returns {{
|
|
96
|
+
* kind: 'windows'|'wsl'|'linux'|'macos'|'unsupported',
|
|
97
|
+
* platform: string,
|
|
98
|
+
* evidence: string,
|
|
99
|
+
* suspect: boolean,
|
|
100
|
+
* distro: string,
|
|
101
|
+
* }}
|
|
102
|
+
*/
|
|
103
|
+
export function detectHostKind({
|
|
104
|
+
platform = process.platform,
|
|
105
|
+
env = process.env,
|
|
106
|
+
readFile,
|
|
107
|
+
exists,
|
|
108
|
+
} = {}) {
|
|
109
|
+
const plat = nonEmptyString(platform) || process.platform
|
|
110
|
+
const environ = env && typeof env === 'object' ? env : process.env
|
|
111
|
+
const readFileSafe = typeof readFile === 'function'
|
|
112
|
+
? readFile
|
|
113
|
+
: (file) => fs.readFileSync(file, 'utf8')
|
|
114
|
+
const existsSafe = typeof exists === 'function'
|
|
115
|
+
? exists
|
|
116
|
+
: (file) => fs.existsSync(file)
|
|
117
|
+
|
|
118
|
+
const base = {
|
|
119
|
+
platform: plat,
|
|
120
|
+
evidence: 'process.platform',
|
|
121
|
+
suspect: false,
|
|
122
|
+
distro: nonEmptyString(environ.WSL_DISTRO_NAME),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (plat === 'win32') {
|
|
126
|
+
return { ...base, kind: HOST_KINDS.WINDOWS }
|
|
127
|
+
}
|
|
128
|
+
if (plat === 'linux') {
|
|
129
|
+
const evidence = detectWslEvidence(environ, readFileSafe, existsSafe)
|
|
130
|
+
if (evidence !== null) {
|
|
131
|
+
return {
|
|
132
|
+
...base,
|
|
133
|
+
kind: HOST_KINDS.WSL,
|
|
134
|
+
evidence,
|
|
135
|
+
// Only the mounted drive: that is a HINT, not a proof.
|
|
136
|
+
suspect: evidence === WSL_SHUTDOWN_EXE,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { ...base, kind: HOST_KINDS.LINUX }
|
|
140
|
+
}
|
|
141
|
+
if (plat === 'darwin') {
|
|
142
|
+
return { ...base, kind: HOST_KINDS.MACOS }
|
|
143
|
+
}
|
|
144
|
+
return { ...base, kind: HOST_KINDS.UNSUPPORTED }
|
|
145
|
+
}
|
package/lib/lifecycle.js
CHANGED
|
@@ -262,10 +262,12 @@ export function selectGoalDisarms(busyAgents, { getGoal, disarmedGoals, parkedGo
|
|
|
262
262
|
* forceRestartWhenBusy?: boolean,
|
|
263
263
|
* }} [options]
|
|
264
264
|
* @returns {{
|
|
265
|
-
* action: 'none'|'wait'|'warn'|'restart'|'force-restart'|'give-up',
|
|
265
|
+
* action: 'none'|'wait'|'warn'|'long-wait'|'restart'|'force-restart'|'give-up',
|
|
266
266
|
* reason: string,
|
|
267
267
|
* log?: string,
|
|
268
268
|
* markSoftWarned?: boolean,
|
|
269
|
+
* blockedBy?: { sessionId: string|null, reason: string }[],
|
|
270
|
+
* waitingSince?: number,
|
|
269
271
|
* }}
|
|
270
272
|
*/
|
|
271
273
|
export function restartDecision(
|
|
@@ -325,11 +327,19 @@ export function restartDecision(
|
|
|
325
327
|
}
|
|
326
328
|
|
|
327
329
|
// Hard deadline reached while a conversation is still running.
|
|
330
|
+
//
|
|
331
|
+
// From here on the plugin does NOT pretend that "everything is fine, we are
|
|
332
|
+
// just waiting a little longer": the wait has become a LONG wait, and the
|
|
333
|
+
// caller (index.js) has to make it VISIBLE — in the log and in the state
|
|
334
|
+
// payload — while KEEPING the user's restart request alive. The request is
|
|
335
|
+
// never silently dropped.
|
|
328
336
|
if (policy === 'wait') {
|
|
329
337
|
return {
|
|
330
|
-
action: '
|
|
338
|
+
action: 'long-wait',
|
|
331
339
|
reason: 'hard-deadline-policy-wait',
|
|
332
340
|
log: 'defer policy wait: still busy after hard deadline',
|
|
341
|
+
blockedBy: busy,
|
|
342
|
+
waitingSince: requestedAt,
|
|
333
343
|
};
|
|
334
344
|
}
|
|
335
345
|
// The hard deadline only forces a restart when the operator explicitly opted in
|
|
@@ -340,11 +350,14 @@ export function restartDecision(
|
|
|
340
350
|
return { action: 'force-restart', reason: 'hard-deadline-force-restart' };
|
|
341
351
|
}
|
|
342
352
|
// forceRestartWhenBusy is disabled (the default): the restart keeps waiting
|
|
343
|
-
// until the idle gate is quiet again, and it never gives up.
|
|
353
|
+
// until the idle gate is quiet again, and it never gives up. The result says
|
|
354
|
+
// so explicitly — a long wait, with the sessions that hold it.
|
|
344
355
|
return {
|
|
345
|
-
action: '
|
|
356
|
+
action: 'long-wait',
|
|
346
357
|
reason: 'hard-deadline-waiting-for-conversations',
|
|
347
358
|
log: 'dsh-autorestart: hard deadline reached but conversations are still running; forceRestartWhenBusy is disabled, so the restart keeps waiting',
|
|
359
|
+
blockedBy: busy,
|
|
360
|
+
waitingSince: requestedAt,
|
|
348
361
|
};
|
|
349
362
|
}
|
|
350
363
|
|