@logicseek/hello-cc 0.1.9 → 1.0.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/CHANGELOG.md +59 -0
- package/README.md +18 -3
- package/README.zh-CN.md +17 -3
- package/bin/hcc.mjs +3 -5656
- package/docs/README.md +8 -0
- package/docs/README.zh-CN.md +6 -0
- package/docs/architecture.md +29 -18
- package/docs/architecture.zh-CN.md +20 -10
- package/docs/commands.md +13 -4
- package/docs/commands.zh-CN.md +13 -4
- package/docs/current-state-review-2026-08.md +341 -0
- package/docs/defect-review-2026-07.md +382 -0
- package/docs/guide.md +39 -3
- package/docs/guide.zh-CN.md +34 -2
- package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +294 -0
- package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +400 -0
- package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +284 -0
- package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +303 -0
- package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +71 -0
- package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +117 -0
- package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +120 -0
- package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +117 -0
- package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +104 -0
- package/lib/cli/app.mjs +642 -0
- package/lib/cli/commands/coordination.mjs +247 -0
- package/lib/cli/commands/doctor.mjs +81 -0
- package/lib/cli/commands/gc.mjs +852 -0
- package/lib/cli/commands/hook.mjs +205 -0
- package/lib/cli/commands/install.mjs +294 -0
- package/lib/cli/commands/lifecycle.mjs +101 -0
- package/lib/cli/commands/lock.mjs +197 -0
- package/lib/cli/commands/misc.mjs +61 -0
- package/lib/cli/commands/msg.mjs +152 -0
- package/lib/cli/commands/peer.mjs +233 -0
- package/lib/cli/commands/query.mjs +96 -0
- package/lib/cli/commands/run.mjs +463 -0
- package/lib/cli/commands/task.mjs +405 -0
- package/lib/cli/commands/team.mjs +131 -0
- package/lib/cli/commands/tmux.mjs +838 -0
- package/lib/cli/commands/up.mjs +135 -0
- package/lib/cli-args.mjs +16 -2
- package/lib/coordination-state.mjs +112 -51
- package/lib/core/coordination/automation.mjs +5 -8
- package/lib/core/coordination/clock-safety.mjs +323 -0
- package/lib/core/coordination/event-retention.mjs +17 -0
- package/lib/core/coordination/gc-plan.mjs +614 -0
- package/lib/core/coordination/lease-renewal.mjs +102 -0
- package/lib/core/coordination/lock-evidence.mjs +77 -0
- package/lib/core/coordination/optimistic-evidence.mjs +26 -0
- package/lib/core/coordination/tasks.mjs +156 -36
- package/lib/core/peers/evidence-runtime.mjs +312 -0
- package/lib/core/peers/evidence.mjs +103 -0
- package/lib/core/peers/hook-owner.mjs +32 -0
- package/lib/core/peers/liveness.mjs +44 -4
- package/lib/core/peers/peer-helpers.mjs +121 -0
- package/lib/core/peers/session.mjs +8 -2
- package/lib/core/peers/tmux-safety.mjs +290 -0
- package/lib/core/sessions/launch.mjs +13 -1
- package/lib/db/connection.mjs +104 -0
- package/lib/db/events.mjs +34 -0
- package/lib/db/migration-backup.mjs +225 -0
- package/lib/db/schema.mjs +102 -5
- package/lib/db/stores/peers.mjs +34 -0
- package/lib/integrations/peers/identity.mjs +62 -16
- package/lib/integrations/shims/script.mjs +111 -9
- package/lib/process/identity.mjs +192 -0
- package/lib/process/pty-lifecycle.mjs +149 -0
- package/lib/runtime/buffer-directory-lease.mjs +47 -0
- package/lib/runtime/buffer-evidence.mjs +336 -0
- package/lib/runtime/buffer-gc-protocol.mjs +167 -0
- package/lib/runtime/buffer-gc.mjs +270 -0
- package/lib/runtime/client.mjs +60 -7
- package/lib/runtime/fatal-shutdown.mjs +54 -0
- package/lib/runtime/project-path.mjs +139 -0
- package/lib/runtime/projects.mjs +320 -40
- package/lib/runtime/state.mjs +227 -27
- package/lib/shared/cleanup-error.mjs +54 -0
- package/lib/shared/clock-grace.mjs +106 -0
- package/lib/shared/errors.mjs +9 -0
- package/lib/shared/file-lock.mjs +370 -0
- package/lib/shared/json-file.mjs +6 -2
- package/lib/shared/redact.mjs +60 -0
- package/lib/shared/socket-lock-worker.mjs +201 -0
- package/lib/terminal/tmux.mjs +78 -4
- package/lib/tmux.mjs +4 -0
- package/lib/ui/help.mjs +53 -15
- package/lib/web/api-version.mjs +42 -0
- package/lib/web/auto-attach.mjs +185 -0
- package/lib/web/buffer-gc-runtime.mjs +175 -0
- package/lib/web/cookie-auth.mjs +119 -0
- package/lib/web/external-sessions.mjs +254 -0
- package/lib/web/http-routes.mjs +542 -0
- package/lib/web/http.mjs +135 -6
- package/lib/web/liveness-reaper.mjs +145 -0
- package/lib/web/peer-actions.mjs +132 -23
- package/lib/web/project-contexts.mjs +178 -0
- package/lib/web/pty-sessions.mjs +203 -0
- package/lib/web/runtime-main.mjs +714 -0
- package/lib/web/runtime.mjs +91 -36
- package/lib/web/session-serialize.mjs +0 -0
- package/lib/web/startup.mjs +333 -0
- package/lib/web/tls.mjs +365 -0
- package/lib/web/tmux-sessions.mjs +1361 -0
- package/lib/web/tmux-stream.mjs +166 -0
- package/lib/web/ui-template.mjs +152 -20
- package/package.json +5 -2
- package/scripts/check-free-identifiers.mjs +290 -0
- package/scripts/regression.mjs +5397 -263
package/lib/runtime/state.mjs
CHANGED
|
@@ -1,15 +1,198 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import process from 'node:process';
|
|
4
|
+
import { compareProcessIdentity, inspectProcessIdentity } from '../process/identity.mjs';
|
|
4
5
|
import { CliError } from '../shared/errors.mjs';
|
|
6
|
+
import { withFileLock } from '../shared/file-lock.mjs';
|
|
7
|
+
import { writeJsonSafe } from '../shared/json-file.mjs';
|
|
5
8
|
import {
|
|
6
9
|
globalRuntimePath,
|
|
7
10
|
runtimePath
|
|
8
11
|
} from './paths.mjs';
|
|
9
|
-
import {
|
|
12
|
+
import { runtimeHttpRequest } from '../web/runtime.mjs';
|
|
13
|
+
import { API_VERSION } from '../web/api-version.mjs';
|
|
10
14
|
|
|
11
15
|
const DEFAULT_PRODUCT_NAME = 'hello-cc';
|
|
12
16
|
const DEFAULT_CLI_NAME = 'hcc';
|
|
17
|
+
export const RUNTIME_POINTER_UNKNOWN_GRACE_MS = 120_000;
|
|
18
|
+
|
|
19
|
+
function completeProcessIdentity(value) {
|
|
20
|
+
return compareProcessIdentity(value, value) === 'live';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function runtimeProcessIdentity({
|
|
24
|
+
pid = process.pid,
|
|
25
|
+
inspect = inspectProcessIdentity
|
|
26
|
+
} = {}) {
|
|
27
|
+
const observed = inspect(pid);
|
|
28
|
+
return observed?.state === 'live' && completeProcessIdentity(observed.identity)
|
|
29
|
+
? observed.identity
|
|
30
|
+
: null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function classifyRuntimePointer(runtime, {
|
|
34
|
+
inspect = inspectProcessIdentity,
|
|
35
|
+
ageMs = 0,
|
|
36
|
+
unknownGraceMs = RUNTIME_POINTER_UNKNOWN_GRACE_MS
|
|
37
|
+
} = {}) {
|
|
38
|
+
if (!Number.isFinite(ageMs) || ageMs < 0 ||
|
|
39
|
+
!Number.isFinite(unknownGraceMs) || unknownGraceMs < 0) {
|
|
40
|
+
throw new TypeError('runtime pointer ages must be non-negative finite numbers');
|
|
41
|
+
}
|
|
42
|
+
const pid = Number(runtime?.pid);
|
|
43
|
+
const storedEvidence = runtimeIdentityEvidence(runtime);
|
|
44
|
+
const stored = storedEvidence.present ? storedEvidence.identity : null;
|
|
45
|
+
if (!Number.isInteger(pid) || pid <= 0 || !completeProcessIdentity(stored) || stored.pid !== pid) {
|
|
46
|
+
return { state: 'unknown', reclaimable: ageMs >= unknownGraceMs };
|
|
47
|
+
}
|
|
48
|
+
const current = inspect(pid);
|
|
49
|
+
if (current?.state === 'dead') return { state: 'dead', reclaimable: true };
|
|
50
|
+
if (current?.state !== 'live' || !completeProcessIdentity(current.identity)) {
|
|
51
|
+
return { state: 'unknown', reclaimable: ageMs >= unknownGraceMs };
|
|
52
|
+
}
|
|
53
|
+
return compareProcessIdentity(stored, current.identity) === 'live'
|
|
54
|
+
? { state: 'alive', reclaimable: false }
|
|
55
|
+
: { state: 'dead', reclaimable: true };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function runtimePointerAge(stat, currentMs, unknownGraceMs) {
|
|
59
|
+
const timestampMs = Math.floor(Math.max(Number(stat.mtimeMs), Number(stat.ctimeMs)));
|
|
60
|
+
if (!Number.isFinite(currentMs) || !Number.isFinite(timestampMs)) {
|
|
61
|
+
throw new TypeError('runtime pointer timestamps must be finite numbers');
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
ageMs: currentMs < timestampMs ? 0 : currentMs - timestampMs,
|
|
65
|
+
cutoffSec: Math.max(0, Math.floor(timestampMs / 1000) + Math.ceil(unknownGraceMs / 1000))
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function reclaimRuntimePointerFiles(files, {
|
|
70
|
+
nowMs = Date.now,
|
|
71
|
+
inspect = inspectProcessIdentity,
|
|
72
|
+
unknownGraceMs = RUNTIME_POINTER_UNKNOWN_GRACE_MS,
|
|
73
|
+
reclaimUnknown = true,
|
|
74
|
+
dryRun = false,
|
|
75
|
+
onReclaim = null,
|
|
76
|
+
withLock = withFileLock
|
|
77
|
+
} = {}) {
|
|
78
|
+
if (typeof reclaimUnknown !== 'boolean') {
|
|
79
|
+
throw new TypeError('reclaimUnknown must be a boolean');
|
|
80
|
+
}
|
|
81
|
+
let blocked = false;
|
|
82
|
+
let reclaimed = 0;
|
|
83
|
+
const outcomes = [];
|
|
84
|
+
for (const suppliedFile of new Set((files || []).map((file) => path.resolve(file)))) {
|
|
85
|
+
if (!fs.existsSync(suppliedFile)) continue;
|
|
86
|
+
let recorded = false;
|
|
87
|
+
const record = (outcome) => {
|
|
88
|
+
recorded = true;
|
|
89
|
+
outcomes.push({ file: suppliedFile, ...outcome });
|
|
90
|
+
};
|
|
91
|
+
try {
|
|
92
|
+
withLock(suppliedFile, (file) => {
|
|
93
|
+
if (!fs.existsSync(file)) {
|
|
94
|
+
record({ state: 'missing', action: 'skipped' });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const stat = fs.lstatSync(file);
|
|
98
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
99
|
+
blocked = true;
|
|
100
|
+
record({ state: 'unknown', action: 'blocked', reason: 'invalid_pointer' });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
104
|
+
let runtime = null;
|
|
105
|
+
try { runtime = JSON.parse(raw); } catch {}
|
|
106
|
+
const timing = runtimePointerAge(stat, nowMs(), unknownGraceMs);
|
|
107
|
+
const evidence = classifyRuntimePointer(runtime, {
|
|
108
|
+
inspect,
|
|
109
|
+
ageMs: timing.ageMs,
|
|
110
|
+
unknownGraceMs
|
|
111
|
+
});
|
|
112
|
+
if (!evidence.reclaimable || (evidence.state === 'unknown' && !reclaimUnknown)) {
|
|
113
|
+
blocked = true;
|
|
114
|
+
record({ state: evidence.state, action: 'blocked', reason: 'owner_not_confirmed_dead' });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const current = fs.lstatSync(file);
|
|
118
|
+
const currentRaw = fs.readFileSync(file, 'utf8');
|
|
119
|
+
if (current.isSymbolicLink() || !current.isFile() ||
|
|
120
|
+
current.dev !== stat.dev || current.ino !== stat.ino ||
|
|
121
|
+
current.size !== stat.size || current.mtimeMs !== stat.mtimeMs ||
|
|
122
|
+
current.ctimeMs !== stat.ctimeMs || currentRaw !== raw) {
|
|
123
|
+
blocked = true;
|
|
124
|
+
record({ state: 'unknown', action: 'blocked', reason: 'pointer_changed' });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (!dryRun) fs.unlinkSync(file);
|
|
128
|
+
reclaimed += 1;
|
|
129
|
+
record({ state: evidence.state, action: dryRun ? 'would-reclaim' : 'reclaimed' });
|
|
130
|
+
onReclaim?.({
|
|
131
|
+
file,
|
|
132
|
+
state: evidence.state,
|
|
133
|
+
gcCutoff: evidence.state === 'unknown' ? timing.cutoffSec : null
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
} catch {
|
|
137
|
+
blocked = true;
|
|
138
|
+
if (!recorded) record({ state: 'unknown', action: 'blocked', reason: 'lock_unavailable' });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { blocked, reclaimed, outcomes };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// A runtime pointer carries the runtime process pid. If that pid is no longer
|
|
145
|
+
// alive, the pointer is stale and must not be returned as a reachable runtime
|
|
146
|
+
// (bg-01): otherwise commands and shims fail against a dead runtime until
|
|
147
|
+
// someone manually clears the file. A pointer without a pid is left alone
|
|
148
|
+
// (we cannot determine liveness, so keep the previous behavior).
|
|
149
|
+
function pidAlive(pid) {
|
|
150
|
+
if (pid === undefined || pid === null || pid === '') return true;
|
|
151
|
+
const numeric = Number(pid);
|
|
152
|
+
if (!Number.isInteger(numeric)) return true;
|
|
153
|
+
try { process.kill(numeric, 0); return true; }
|
|
154
|
+
catch (e) { return e.code === 'EPERM'; }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function runtimeIdentityEvidence(runtime) {
|
|
158
|
+
const snakeCase = Object.hasOwn(runtime || {}, 'process_identity');
|
|
159
|
+
const camelCase = Object.hasOwn(runtime || {}, 'processIdentity');
|
|
160
|
+
if (!snakeCase && !camelCase) return { present: false, identity: null };
|
|
161
|
+
if (snakeCase && camelCase) return { present: true, identity: null };
|
|
162
|
+
return {
|
|
163
|
+
present: true,
|
|
164
|
+
identity: snakeCase ? runtime.process_identity : runtime.processIdentity
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function runtimePointerMatchesCurrentProcess(runtime, {
|
|
169
|
+
inspect = inspectProcessIdentity
|
|
170
|
+
} = {}) {
|
|
171
|
+
const storedEvidence = runtimeIdentityEvidence(runtime);
|
|
172
|
+
if (!storedEvidence.present) return pidAlive(runtime?.pid);
|
|
173
|
+
const stored = storedEvidence.identity;
|
|
174
|
+
if (!completeProcessIdentity(stored)) return false;
|
|
175
|
+
const pid = Number(runtime?.pid);
|
|
176
|
+
if (!Number.isInteger(pid) || pid <= 0 || stored.pid !== pid) return false;
|
|
177
|
+
return classifyRuntimePointer(runtime, { ageMs: 0, inspect }).state === 'alive';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function runtimeMetadataMatchesPointer(runtime, metadata) {
|
|
181
|
+
if (Object.hasOwn(runtime || {}, 'product') &&
|
|
182
|
+
(typeof runtime.product !== 'string' || !runtime.product ||
|
|
183
|
+
metadata?.product !== runtime.product)) return false;
|
|
184
|
+
const storedEvidence = runtimeIdentityEvidence(runtime);
|
|
185
|
+
if (!storedEvidence.present) return true;
|
|
186
|
+
const stored = storedEvidence.identity;
|
|
187
|
+
const observedEvidence = runtimeIdentityEvidence(metadata);
|
|
188
|
+
if (!completeProcessIdentity(stored) || !observedEvidence.present) return false;
|
|
189
|
+
const pointerPid = Number(runtime?.pid);
|
|
190
|
+
const metadataPid = Number(metadata?.pid);
|
|
191
|
+
const observed = observedEvidence.identity;
|
|
192
|
+
return Number.isInteger(pointerPid) && pointerPid > 0 && stored.pid === pointerPid &&
|
|
193
|
+
metadataPid === pointerPid && completeProcessIdentity(observed) &&
|
|
194
|
+
observed.pid === metadataPid && compareProcessIdentity(stored, observed) === 'live';
|
|
195
|
+
}
|
|
13
196
|
|
|
14
197
|
export function readGlobalRuntimeFile() {
|
|
15
198
|
const file = globalRuntimePath();
|
|
@@ -17,33 +200,34 @@ export function readGlobalRuntimeFile() {
|
|
|
17
200
|
try {
|
|
18
201
|
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
19
202
|
} catch {
|
|
20
|
-
|
|
203
|
+
// Do not delete on a parse failure: a torn/partial write must not orphan a
|
|
204
|
+
// healthy runtime pointer. Leave it for health probing / restart to reclaim.
|
|
21
205
|
return null;
|
|
22
206
|
}
|
|
23
207
|
}
|
|
24
208
|
|
|
25
209
|
export function writeGlobalRuntime(runtime) {
|
|
26
210
|
const file = globalRuntimePath();
|
|
27
|
-
|
|
28
|
-
fs.writeFileSync(file, JSON.stringify(runtime, null, 2), { mode: 0o600 });
|
|
29
|
-
fs.chmodSync(file, 0o600);
|
|
211
|
+
withFileLock(file, (lockedFile) => writeJsonSafe(lockedFile, runtime, { mode: 0o600 }));
|
|
30
212
|
return file;
|
|
31
213
|
}
|
|
32
214
|
|
|
33
215
|
export function writeRuntime(ctx, runtime) {
|
|
34
216
|
const file = runtimePath(ctx);
|
|
35
|
-
|
|
36
|
-
fs.writeFileSync(file, JSON.stringify(runtime, null, 2), { mode: 0o600 });
|
|
37
|
-
fs.chmodSync(file, 0o600);
|
|
217
|
+
withFileLock(file, (lockedFile) => writeJsonSafe(lockedFile, runtime, { mode: 0o600 }));
|
|
38
218
|
return file;
|
|
39
219
|
}
|
|
40
220
|
|
|
41
221
|
export function readRuntime(ctx, opts = {}) {
|
|
42
222
|
const localOnly = Boolean(opts.localOnly || process.env.HCC_RUNTIME_LOCAL_ONLY === '1');
|
|
223
|
+
const inspectRuntimeProcessIdentity = typeof opts.inspectProcessIdentity === 'function'
|
|
224
|
+
? opts.inspectProcessIdentity
|
|
225
|
+
: inspectProcessIdentity;
|
|
43
226
|
if (process.env.HCC_RUNTIME_URL) {
|
|
44
227
|
return {
|
|
45
228
|
base_url: process.env.HCC_RUNTIME_URL,
|
|
46
229
|
token: process.env.HCC_RUNTIME_TOKEN || '',
|
|
230
|
+
tls_ca_file: process.env.HCC_RUNTIME_CA || undefined,
|
|
47
231
|
source: 'env'
|
|
48
232
|
};
|
|
49
233
|
}
|
|
@@ -52,14 +236,20 @@ export function readRuntime(ctx, opts = {}) {
|
|
|
52
236
|
try {
|
|
53
237
|
const runtime = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
54
238
|
if (!runtime.base_url) throw new Error('missing base_url');
|
|
239
|
+
if (!runtimePointerMatchesCurrentProcess(runtime, { inspect: inspectRuntimeProcessIdentity })) {
|
|
240
|
+
throw new Error('runtime process identity does not match');
|
|
241
|
+
}
|
|
55
242
|
return { ...runtime, source: file };
|
|
56
243
|
} catch {
|
|
57
|
-
|
|
244
|
+
// Do not delete on a torn/partial read; fall through to the global
|
|
245
|
+
// runtime and let health probing / restart reclaim a truly dead file.
|
|
58
246
|
}
|
|
59
247
|
}
|
|
60
248
|
if (!localOnly) {
|
|
61
249
|
const global = readGlobalRuntimeFile();
|
|
62
|
-
if (global?.base_url
|
|
250
|
+
if (global?.base_url && runtimePointerMatchesCurrentProcess(global, {
|
|
251
|
+
inspect: inspectRuntimeProcessIdentity
|
|
252
|
+
})) {
|
|
63
253
|
return { ...global, source: globalRuntimePath(), global: true };
|
|
64
254
|
}
|
|
65
255
|
}
|
|
@@ -77,12 +267,14 @@ export function readRuntimeFile(ctx) {
|
|
|
77
267
|
|
|
78
268
|
export async function probeRuntime(runtime) {
|
|
79
269
|
if (!runtime?.base_url) return false;
|
|
80
|
-
const url = runtimeApiUrl(runtime, '/api/runtime');
|
|
81
270
|
const headers = {};
|
|
82
271
|
if (runtime.token) headers.Authorization = `Bearer ${runtime.token}`;
|
|
83
272
|
try {
|
|
84
|
-
|
|
85
|
-
|
|
273
|
+
// xx-03: probing must not hang the CLI indefinitely against a stalled runtime.
|
|
274
|
+
const response = await runtimeHttpRequest(runtime, '/api/runtime', { headers, timeoutMs: 3000 });
|
|
275
|
+
if (!response.ok) return false;
|
|
276
|
+
const metadata = JSON.parse(response.text);
|
|
277
|
+
return metadata?.api_version === API_VERSION && runtimeMetadataMatchesPointer(runtime, metadata);
|
|
86
278
|
} catch {
|
|
87
279
|
return false;
|
|
88
280
|
}
|
|
@@ -112,20 +304,28 @@ export async function readHealthyGlobalRuntime() {
|
|
|
112
304
|
|
|
113
305
|
export function clearRuntime(ctx, pid = process.pid) {
|
|
114
306
|
const file = runtimePath(ctx);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
307
|
+
try {
|
|
308
|
+
withFileLock(file, (lockedFile) => {
|
|
309
|
+
if (!fs.existsSync(lockedFile)) return;
|
|
310
|
+
try {
|
|
311
|
+
const runtime = JSON.parse(fs.readFileSync(lockedFile, 'utf8'));
|
|
312
|
+
if (runtime.pid === pid) fs.rmSync(lockedFile, { force: true });
|
|
313
|
+
} catch {
|
|
314
|
+
// Leave an unparseable pointer in place: only remove when we can confirm
|
|
315
|
+
// it belongs to this pid, so a torn write never deletes another runtime's file.
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
} catch {}
|
|
123
319
|
const globalFile = globalRuntimePath();
|
|
124
|
-
if (!fs.existsSync(globalFile)) return;
|
|
125
320
|
try {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
321
|
+
withFileLock(globalFile, (lockedFile) => {
|
|
322
|
+
if (!fs.existsSync(lockedFile)) return;
|
|
323
|
+
try {
|
|
324
|
+
const runtime = JSON.parse(fs.readFileSync(lockedFile, 'utf8'));
|
|
325
|
+
if (runtime.pid === pid) fs.rmSync(lockedFile, { force: true });
|
|
326
|
+
} catch {
|
|
327
|
+
// Same as above: do not blind-delete a torn global runtime pointer.
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
} catch {}
|
|
131
331
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const CLEANUP_FAILURE = Symbol('cleanup-failure');
|
|
2
|
+
|
|
3
|
+
export function aggregateCleanupFailure(
|
|
4
|
+
primaryError,
|
|
5
|
+
cleanupError,
|
|
6
|
+
message,
|
|
7
|
+
cleanup = {}
|
|
8
|
+
) {
|
|
9
|
+
const error = new AggregateError(
|
|
10
|
+
[primaryError, cleanupError],
|
|
11
|
+
message,
|
|
12
|
+
{ cause: primaryError }
|
|
13
|
+
);
|
|
14
|
+
error[CLEANUP_FAILURE] = true;
|
|
15
|
+
error.cleanup = Object.freeze({ ...cleanup });
|
|
16
|
+
return error;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function cleanupFailureRoot(error) {
|
|
20
|
+
let current = error;
|
|
21
|
+
let foundCleanup = false;
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
while (current instanceof AggregateError && current[CLEANUP_FAILURE] === true) {
|
|
24
|
+
if (seen.has(current) || current.errors?.[0] !== current.cause) return null;
|
|
25
|
+
seen.add(current);
|
|
26
|
+
foundCleanup = true;
|
|
27
|
+
current = current.cause;
|
|
28
|
+
}
|
|
29
|
+
return foundCleanup ? current : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function replaceCleanupFailureRoot(error, replacement) {
|
|
33
|
+
const seen = new Set();
|
|
34
|
+
function rebuild(current) {
|
|
35
|
+
if (!(current instanceof AggregateError) || current[CLEANUP_FAILURE] !== true) {
|
|
36
|
+
return replacement;
|
|
37
|
+
}
|
|
38
|
+
if (seen.has(current) || current.errors?.length !== 2 || current.errors[0] !== current.cause) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
seen.add(current);
|
|
42
|
+
const primaryError = rebuild(current.cause);
|
|
43
|
+
if (primaryError === null) return null;
|
|
44
|
+
return aggregateCleanupFailure(
|
|
45
|
+
primaryError,
|
|
46
|
+
current.errors[1],
|
|
47
|
+
current.message,
|
|
48
|
+
current.cleanup
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!(error instanceof AggregateError) || error[CLEANUP_FAILURE] !== true) return null;
|
|
53
|
+
return rebuild(error);
|
|
54
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Wall-clock jump protection (hb-05). All age/TTL comparisons use wall-clock
|
|
2
|
+
// seconds (last_seen_at / expires_at are persisted across processes), so a
|
|
3
|
+
// forward jump (macOS sleep, NTP step) makes every peer look stale and every
|
|
4
|
+
// lock look expired at once. The long-lived web runtime detects the jump and
|
|
5
|
+
// persists a grace deadline in the DB meta table; every process (runtime and
|
|
6
|
+
// short-lived CLIs, which share the same system clock) then suppresses
|
|
7
|
+
// age-based decisions until the grace window passes. This is deliberately
|
|
8
|
+
// conservative: keeping a truly-dead peer alive for one grace window is safe;
|
|
9
|
+
// reaping/taking-over a live peer is not.
|
|
10
|
+
|
|
11
|
+
export const CLOCK_GRACE_SEC = 120;
|
|
12
|
+
|
|
13
|
+
function parsePersistedDeadline(value) {
|
|
14
|
+
if (typeof value !== 'string' || !/^(0|[1-9][0-9]*)$/.test(value)) {
|
|
15
|
+
throw new Error('invalid persisted deadline for clock grace');
|
|
16
|
+
}
|
|
17
|
+
const persisted = Number(value);
|
|
18
|
+
if (!Number.isSafeInteger(persisted) || persisted < 0) {
|
|
19
|
+
throw new Error('invalid persisted deadline for clock grace');
|
|
20
|
+
}
|
|
21
|
+
return persisted;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function missingMetaTable(error) {
|
|
25
|
+
return /no such table:\s*(?:main\.)?meta\b/i.test(String(error?.message || ''));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function readClockGraceUntil(db) {
|
|
29
|
+
let row;
|
|
30
|
+
try {
|
|
31
|
+
row = db.prepare("SELECT value FROM meta WHERE key = 'clock_grace_until'").get();
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (missingMetaTable(error)) return 0;
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
if (!row) return 0;
|
|
37
|
+
try {
|
|
38
|
+
return parsePersistedDeadline(row.value);
|
|
39
|
+
} catch {
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function writeClockGraceUntil(db, until) {
|
|
45
|
+
if (typeof until !== 'number' || !Number.isSafeInteger(until) || until < 0) {
|
|
46
|
+
throw new TypeError('clock grace deadline must be a non-negative safe integer');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let row = db.prepare(`
|
|
50
|
+
INSERT INTO meta(key, value) VALUES ('clock_grace_until', ?)
|
|
51
|
+
ON CONFLICT(key) DO UPDATE SET value = CAST(
|
|
52
|
+
MAX(CAST(meta.value AS INTEGER), CAST(excluded.value AS INTEGER)) AS TEXT
|
|
53
|
+
)
|
|
54
|
+
WHERE (meta.value = '0' OR (
|
|
55
|
+
meta.value GLOB '[1-9]*'
|
|
56
|
+
AND meta.value NOT GLOB '*[^0-9]*'
|
|
57
|
+
))
|
|
58
|
+
AND CAST(meta.value AS INTEGER) BETWEEN 0 AND 9007199254740991
|
|
59
|
+
RETURNING value AS deadline
|
|
60
|
+
`).get(String(until));
|
|
61
|
+
|
|
62
|
+
if (!row) {
|
|
63
|
+
// CS-08: the stored value is non-canonical (corrupt or legacy). Repair it by
|
|
64
|
+
// overwriting with the new deadline instead of failing the whole owning
|
|
65
|
+
// transaction.
|
|
66
|
+
row = db.prepare(`
|
|
67
|
+
INSERT INTO meta(key, value) VALUES ('clock_grace_until', ?)
|
|
68
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
69
|
+
RETURNING value AS deadline
|
|
70
|
+
`).get(String(until));
|
|
71
|
+
}
|
|
72
|
+
if (!row) throw new Error('clock grace deadline could not be persisted');
|
|
73
|
+
return parsePersistedDeadline(row.deadline);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// True while the grace window is active at wall-clock second `nowSec`.
|
|
77
|
+
export function clockGraceSuppressed(nowSec, graceUntil) {
|
|
78
|
+
return Number(graceUntil) > Number(nowSec);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Classify a wall-clock probe delta (ms between two ticks of a known-interval
|
|
82
|
+
// poller). Returns null when the delta is normal, or { kind } for a forward
|
|
83
|
+
// jump (sleep / NTP step forward) or backward jump (NTP step back).
|
|
84
|
+
export function classifyClockJump(deltaMs, { tickMs = 30000, forwardFactor = 3, backwardMs = 5000 } = {}) {
|
|
85
|
+
if (deltaMs > tickMs * forwardFactor) return { kind: 'forward', deltaMs };
|
|
86
|
+
if (deltaMs < -backwardMs) return { kind: 'backward', deltaMs };
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function classifyClockDrift({ wallDeltaMs, monotonicDeltaMs } = {}, {
|
|
91
|
+
forwardMs = 60000,
|
|
92
|
+
backwardMs = 5000
|
|
93
|
+
} = {}) {
|
|
94
|
+
if (![wallDeltaMs, monotonicDeltaMs, forwardMs, backwardMs].every(Number.isFinite) ||
|
|
95
|
+
monotonicDeltaMs < 0 || forwardMs < 0 || backwardMs < 0) {
|
|
96
|
+
throw new TypeError('clock drift inputs must be finite non-negative intervals');
|
|
97
|
+
}
|
|
98
|
+
const driftMs = wallDeltaMs - monotonicDeltaMs;
|
|
99
|
+
if (driftMs > forwardMs) {
|
|
100
|
+
return { kind: 'forward', driftMs, wallDeltaMs, monotonicDeltaMs };
|
|
101
|
+
}
|
|
102
|
+
if (driftMs < -backwardMs) {
|
|
103
|
+
return { kind: 'backward', driftMs, wallDeltaMs, monotonicDeltaMs };
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
package/lib/shared/errors.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { cleanupFailureRoot } from './cleanup-error.mjs';
|
|
2
|
+
|
|
1
3
|
export class CliError extends Error {
|
|
2
4
|
constructor(code, message, extra = {}) {
|
|
3
5
|
super(message);
|
|
@@ -5,3 +7,10 @@ export class CliError extends Error {
|
|
|
5
7
|
this.extra = extra;
|
|
6
8
|
}
|
|
7
9
|
}
|
|
10
|
+
|
|
11
|
+
export function publicCliFailure(error) {
|
|
12
|
+
if (error instanceof CliError) return { error, cleanupFailed: false };
|
|
13
|
+
const rootError = cleanupFailureRoot(error);
|
|
14
|
+
if (rootError instanceof CliError) return { error: rootError, cleanupFailed: true };
|
|
15
|
+
return null;
|
|
16
|
+
}
|