@ran-sh/dsh-crew 2.0.10 → 2.0.11
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/package.json
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, readdirSync, rmSync, statSync, lstatSync } from 'node:fs';
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
7
8
|
import { dirname, join, resolve, relative } from 'node:path';
|
|
8
9
|
import { fileURLToPath } from 'node:url';
|
|
9
10
|
import { homedir } from 'node:os';
|
|
@@ -178,26 +179,31 @@ function claudePluginRootReady(root) {
|
|
|
178
179
|
&& mcp.args[0] === '${CLAUDE_PLUGIN_ROOT}/src/server.mjs';
|
|
179
180
|
}
|
|
180
181
|
|
|
181
|
-
function managedClaudeFileManifest(root, { maxFiles = 512, maxBytes = 8 * 1024 * 1024 } = {}) {
|
|
182
|
+
export function managedClaudeFileManifest(root, { maxFiles = 512, maxBytes = 8 * 1024 * 1024, maxDirectories = 256, maxDepth = 12 } = {}) {
|
|
182
183
|
const files = [];
|
|
183
184
|
let bytes = 0;
|
|
185
|
+
let directories = 0;
|
|
184
186
|
const add = (file, relativePath) => {
|
|
185
187
|
const info = lstatSync(file);
|
|
186
188
|
if (info.isSymbolicLink() || !info.isFile()) throw new Error('unsupported snapshot entry');
|
|
189
|
+
// Bound before reading, not after: reading first and rejecting afterwards
|
|
190
|
+
// loads a whole oversized file on the strength of its name.
|
|
191
|
+
if (files.length >= maxFiles || bytes + info.size > maxBytes) throw new Error('snapshot manifest bound exceeded');
|
|
187
192
|
const content = readFileSync(file);
|
|
188
193
|
bytes += content.length;
|
|
189
|
-
if (bytes > maxBytes || files.length >= maxFiles) throw new Error('snapshot manifest bound exceeded');
|
|
190
194
|
files.push([relativePath.replace(/\\/g, '/'), createHash('sha256').update(content).digest('hex')]);
|
|
191
195
|
};
|
|
192
|
-
const walk = (directory, relativeDirectory) => {
|
|
196
|
+
const walk = (directory, relativeDirectory, depth = 0) => {
|
|
197
|
+
if (depth > maxDepth) throw new Error('snapshot directory depth exceeded');
|
|
193
198
|
if (!existsSync(directory)) return;
|
|
199
|
+
if (++directories > maxDirectories) throw new Error('snapshot directory count exceeded');
|
|
194
200
|
const info = lstatSync(directory);
|
|
195
201
|
if (info.isSymbolicLink() || !info.isDirectory()) throw new Error('unsupported snapshot directory');
|
|
196
202
|
for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
197
203
|
const file = join(directory, entry.name);
|
|
198
204
|
const relativePath = join(relativeDirectory, entry.name);
|
|
199
205
|
if (entry.isSymbolicLink()) throw new Error('snapshot symlink not allowed');
|
|
200
|
-
if (entry.isDirectory()) walk(file, relativePath);
|
|
206
|
+
if (entry.isDirectory()) walk(file, relativePath, depth + 1);
|
|
201
207
|
else if (entry.isFile()) add(file, relativePath);
|
|
202
208
|
else throw new Error('unsupported snapshot entry');
|
|
203
209
|
}
|
|
@@ -229,7 +235,8 @@ function claudeSnapshotReady(home, root, { scope = null } = {}) {
|
|
|
229
235
|
const record = installed?.plugins?.[PLUGIN_KEY];
|
|
230
236
|
const entries = Array.isArray(record) ? record : [record];
|
|
231
237
|
return entries.some((entry) => (scope === null || entry?.scope === scope)
|
|
232
|
-
&& sameManagedClaudeFiles(root, entry?.installPath)
|
|
238
|
+
&& sameManagedClaudeFiles(root, entry?.installPath)
|
|
239
|
+
&& claudeSnapshotResolvable(entry?.installPath));
|
|
233
240
|
}
|
|
234
241
|
|
|
235
242
|
function claudePermissionsReady(settings) {
|
|
@@ -555,6 +562,122 @@ export function claudeSnapshotSettleMs(err) {
|
|
|
555
562
|
return err?.code === 'ETIMEDOUT' ? CLAUDE_SNAPSHOT_SETTLE_MS : 0;
|
|
556
563
|
}
|
|
557
564
|
|
|
565
|
+
/**
|
|
566
|
+
* Whether the snapshot can actually run, not merely whether its files match.
|
|
567
|
+
*
|
|
568
|
+
* `src/server.mjs` is the MCP server Claude Code launches, and it imports its
|
|
569
|
+
* runtime dependencies by bare specifier — so a copy interrupted between `src/`
|
|
570
|
+
* and `node_modules/` leaves a snapshot whose compared files are all present and
|
|
571
|
+
* whose server cannot start. Resolution is asked from the snapshot's own
|
|
572
|
+
* `package.json` against the dependencies it declares, so this stays bounded by
|
|
573
|
+
* what the package says it needs rather than by walking installed packages. A
|
|
574
|
+
* snapshot carrying no `package.json` declares nothing, and fails the file
|
|
575
|
+
* comparison instead.
|
|
576
|
+
*/
|
|
577
|
+
function claudeSnapshotResolvable(snapshotRoot) {
|
|
578
|
+
if (typeof snapshotRoot !== 'string' || !snapshotRoot.trim()) return false;
|
|
579
|
+
const manifest = readJson(join(snapshotRoot, 'package.json'), null);
|
|
580
|
+
const declared = manifest && manifest.dependencies && typeof manifest.dependencies === 'object'
|
|
581
|
+
? Object.keys(manifest.dependencies)
|
|
582
|
+
: [];
|
|
583
|
+
if (!declared.length) return true;
|
|
584
|
+
let fromSnapshot;
|
|
585
|
+
try { fromSnapshot = createRequire(join(snapshotRoot, 'package.json')); } catch { return false; }
|
|
586
|
+
return declared.every((dependency) => {
|
|
587
|
+
try { fromSnapshot.resolve(dependency); return true; } catch { /* try the manifest path below */ }
|
|
588
|
+
try { fromSnapshot.resolve(`${dependency}/package.json`); return true; } catch { return false; }
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* How to run the `claude` CLI, as an executable plus an argument array.
|
|
594
|
+
*
|
|
595
|
+
* Paths go in as arguments and never into a shell string: `JSON.stringify` quotes
|
|
596
|
+
* for JSON, not for a command processor, so a path containing `%NAME%` was
|
|
597
|
+
* expanded before the CLI ever saw it. Windows has to reach the CLI through a
|
|
598
|
+
* command processor because a `.cmd` shim cannot be executed directly, so an
|
|
599
|
+
* argument the processor would act on is refused rather than escaped — a refused
|
|
600
|
+
* install is recoverable, a silently relocated path is not.
|
|
601
|
+
*/
|
|
602
|
+
export function claudeCliInvocation(args, { platform = process.platform, environment = process.env } = {}) {
|
|
603
|
+
const argv = args.map((arg) => String(arg));
|
|
604
|
+
if (platform !== 'win32') return { command: 'claude', args: argv };
|
|
605
|
+
const unsafe = argv.find((arg) => /[\0\r\n"%!^&|<>]/.test(arg));
|
|
606
|
+
if (unsafe !== undefined) throw new Error(`unsafe claude CLI argument: ${unsafe}`);
|
|
607
|
+
return {
|
|
608
|
+
command: environment.ComSpec || environment.COMSPEC || 'cmd.exe',
|
|
609
|
+
args: ['/d', '/s', '/c', ['claude.cmd', ...argv.map((arg) => `"${arg}"`)].join(' ')],
|
|
610
|
+
windowsVerbatimArguments: true,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Kill a process and everything it started.
|
|
616
|
+
*
|
|
617
|
+
* The killed shell is not the process doing the work: `claude` is a grandchild
|
|
618
|
+
* that Windows does not reach when only its parent is terminated. An `uninstall`
|
|
619
|
+
* left running that way finishes *after* the install that followed it and deletes
|
|
620
|
+
* the plugin the install just registered, so the tree has to go, and the caller
|
|
621
|
+
* must not return until it has.
|
|
622
|
+
*/
|
|
623
|
+
async function killClaudeProcessTree(pid, { platform = process.platform } = {}) {
|
|
624
|
+
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
625
|
+
const { spawn } = await import('node:child_process');
|
|
626
|
+
if (platform === 'win32') {
|
|
627
|
+
await new Promise((done) => {
|
|
628
|
+
let settled = false;
|
|
629
|
+
const finish = () => { if (!settled) { settled = true; done(); } };
|
|
630
|
+
const killer = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
631
|
+
killer.on('error', finish);
|
|
632
|
+
killer.on('close', finish);
|
|
633
|
+
});
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
try { process.kill(-pid, 'SIGKILL'); } catch { try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ } }
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Run one CLI step to completion, or end it and its descendants on timeout.
|
|
641
|
+
*
|
|
642
|
+
* Resolves only once nothing from this step can still be running, so the next
|
|
643
|
+
* step cannot be raced by the previous one. A timeout is reported in the shape
|
|
644
|
+
* `execSync` uses, so the settle rule reads it the same way.
|
|
645
|
+
*/
|
|
646
|
+
async function runClaudeStep(args, { timeoutMs = CLAUDE_PLUGIN_TIMEOUT_MS } = {}) {
|
|
647
|
+
let invocation;
|
|
648
|
+
try { invocation = claudeCliInvocation(args); }
|
|
649
|
+
catch (error) { return { ok: false, timedOut: false, status: null, error }; }
|
|
650
|
+
const { spawn } = await import('node:child_process');
|
|
651
|
+
return await new Promise((resolve) => {
|
|
652
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
653
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
654
|
+
windowsHide: true,
|
|
655
|
+
windowsVerbatimArguments: invocation.windowsVerbatimArguments === true,
|
|
656
|
+
detached: process.platform !== 'win32',
|
|
657
|
+
});
|
|
658
|
+
let timedOut = false;
|
|
659
|
+
let kill = null;
|
|
660
|
+
let stderr = '';
|
|
661
|
+
let settled = false;
|
|
662
|
+
const finish = () => {
|
|
663
|
+
if (settled) return;
|
|
664
|
+
settled = true;
|
|
665
|
+
clearTimeout(timer);
|
|
666
|
+
const detail = stderr.slice(-400);
|
|
667
|
+
// Never report a step finished while its tree may still be writing.
|
|
668
|
+
const done = () => resolve(timedOut
|
|
669
|
+
? { ok: false, timedOut: true, status: null, detail, error: Object.assign(new Error('claude CLI step timed out'), { code: 'ETIMEDOUT', signal: 'SIGTERM', status: null }) }
|
|
670
|
+
: { ok: code === 0, timedOut: false, status: code, detail, error: null });
|
|
671
|
+
if (kill) kill.then(done, done); else done();
|
|
672
|
+
};
|
|
673
|
+
let code = null;
|
|
674
|
+
const timer = setTimeout(() => { timedOut = true; kill = killClaudeProcessTree(child.pid); }, timeoutMs);
|
|
675
|
+
child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
|
|
676
|
+
child.on('error', (error) => { stderr += String(error?.message ?? error); code = null; finish(); });
|
|
677
|
+
child.on('close', (value) => { code = value; finish(); });
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
558
681
|
export async function installClaudeCode({ home = homedir(), statusline = false, root = ROOT } = {}) {
|
|
559
682
|
const actions = [];
|
|
560
683
|
|
|
@@ -573,7 +696,21 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
|
|
|
573
696
|
mkdirSync(dirname(settingsFile), { recursive: true });
|
|
574
697
|
const bak = backup(settingsFile);
|
|
575
698
|
if (bak) actions.push(`backup: ${bak}`);
|
|
576
|
-
const
|
|
699
|
+
const settingsPresent = existsSync(settingsFile);
|
|
700
|
+
const parsed = readJson(settingsFile, null);
|
|
701
|
+
if (parsed === null && settingsPresent) {
|
|
702
|
+
// Present but unparseable is not "no settings yet". Rebuilding it as an
|
|
703
|
+
// empty configuration is exactly how an operator's settings disappear, with
|
|
704
|
+
// a backup left for them to restore by hand — the file is theirs, and an
|
|
705
|
+
// unreadable one is not permission to replace it.
|
|
706
|
+
return {
|
|
707
|
+
ok: false,
|
|
708
|
+
code: 'CLAUDE_SETTINGS_UNREADABLE',
|
|
709
|
+
error: `${settingsFile} is present but not valid JSON`,
|
|
710
|
+
actions,
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
const settings = parsed ?? {};
|
|
577
714
|
|
|
578
715
|
// Both fields are records (see json.schemastore.org/claude-code-settings.json).
|
|
579
716
|
// Older versions of this installer wrote arrays, which Claude Code ignores
|
|
@@ -653,7 +790,7 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
|
|
|
653
790
|
// The CLI is best-effort, but a host that does not have it must not be told to
|
|
654
791
|
// run it. The checkout entry already gates on this; doing it here too keeps the
|
|
655
792
|
// two entries describing the same machine the same way.
|
|
656
|
-
const {
|
|
793
|
+
const { spawnSync } = await import('node:child_process');
|
|
657
794
|
const claudePresent = spawnSync(/^win/.test(process.platform) ? 'where' : 'which', ['claude'], {
|
|
658
795
|
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
659
796
|
}).status === 0;
|
|
@@ -661,37 +798,41 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
|
|
|
661
798
|
actions.push('cli: skipped (claude not found)');
|
|
662
799
|
return { ok: true, detected: false, actions };
|
|
663
800
|
}
|
|
664
|
-
//
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
801
|
+
// Each step runs to completion — including ending the whole process tree on a
|
|
802
|
+
// timeout — before the next one starts, and the settle window is the widest any
|
|
803
|
+
// step asks for. Two things this replaces: an `uninstall` left running past its
|
|
804
|
+
// timeout finished *after* the install that followed and deleted the plugin the
|
|
805
|
+
// install had just registered, and a later step's ordinary failure used to erase
|
|
806
|
+
// an earlier step's timeout from the evidence.
|
|
807
|
+
// 300s, not 120: the install runs after the uninstall, so it does a real copy of
|
|
808
|
+
// the plugin tree rather than the no-op an already-installed plugin gets.
|
|
809
|
+
// Measured on this machine: `marketplace add` 3s, `uninstall` 3s, `install` 163s
|
|
810
|
+
// (no-op install: 6s). The old ceiling killed the copy partway and left Claude
|
|
811
|
+
// Code without the plugin the same run had just removed.
|
|
812
|
+
let settleMs = 0;
|
|
813
|
+
const noteStep = (step) => { settleMs = Math.max(settleMs, claudeSnapshotSettleMs(step?.error)); };
|
|
814
|
+
|
|
815
|
+
const marketplace = await runClaudeStep(['plugin', 'marketplace', 'add', mpDir]);
|
|
816
|
+
noteStep(marketplace);
|
|
817
|
+
actions.push(marketplace.ok
|
|
818
|
+
? 'cli: marketplace registered'
|
|
819
|
+
: `cli: marketplace add failed${marketplace.timedOut ? ' (timed out)' : ''}`);
|
|
820
|
+
|
|
821
|
+
// `plugin install` on an already-installed plugin is a no-op and leaves a
|
|
822
|
+
// stale snapshot in ~/.claude/plugins/cache — uninstall first so an update
|
|
823
|
+
// always re-copies the current code.
|
|
824
|
+
noteStep(await runClaudeStep(['plugin', 'uninstall', PLUGIN_KEY]));
|
|
825
|
+
|
|
826
|
+
// Newer Claude Code (>= 2.1.x) dropped the -y flag; older builds accepted it.
|
|
827
|
+
// Try without it first, fall back to the legacy flag.
|
|
828
|
+
let install = await runClaudeStep(['plugin', 'install', PLUGIN_KEY, '--scope', CLAUDE_PLUGIN_SCOPE]);
|
|
829
|
+
if (!install.ok && !install.timedOut && /unknown option/i.test(String(install.detail ?? ''))) {
|
|
830
|
+
install = await runClaudeStep(['plugin', 'install', PLUGIN_KEY, '--scope', CLAUDE_PLUGIN_SCOPE, '-y']);
|
|
694
831
|
}
|
|
832
|
+
noteStep(install);
|
|
833
|
+
actions.push(install.ok
|
|
834
|
+
? `cli: plugin snapshot refreshed (${PLUGIN_KEY})`
|
|
835
|
+
: `cli: plugin install failed — run manually: claude plugin install ${PLUGIN_KEY}${install.timedOut ? ' (timed out)' : ''}`);
|
|
695
836
|
// Report the state that resulted, not the step that was attempted. The CLI is
|
|
696
837
|
// best-effort — a machine without `claude` is a supported install, and its
|
|
697
838
|
// settings alone are correct — but a CLI that is present and slow, timed out,
|
|
@@ -706,7 +847,7 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
|
|
|
706
847
|
// is still running keeps writing while this function has already given up on
|
|
707
848
|
// it. Wait for the snapshot to settle before reporting it missing, or the
|
|
708
849
|
// update says "not loaded" about a plugin that is loading.
|
|
709
|
-
const settleDeadline = Date.now() +
|
|
850
|
+
const settleDeadline = Date.now() + settleMs;
|
|
710
851
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
711
852
|
while (Date.now() < settleDeadline) {
|
|
712
853
|
await wait(CLAUDE_SNAPSHOT_POLL_MS);
|
package/src/runtime-identity.mjs
CHANGED
|
@@ -36,7 +36,7 @@ export {
|
|
|
36
36
|
// included in the identity contract.
|
|
37
37
|
const RUNTIME_ID = randomUUID();
|
|
38
38
|
|
|
39
|
-
export const RUNTIME_VERSION = '2.0.
|
|
39
|
+
export const RUNTIME_VERSION = '2.0.11';
|
|
40
40
|
export const HUB_PROTOCOL_VERSION = 1;
|
|
41
41
|
|
|
42
42
|
export const HUB_CAPABILITIES = Object.freeze([
|
|
@@ -437,8 +437,16 @@ function Get-HealthState {
|
|
|
437
437
|
$diskReadable = $true
|
|
438
438
|
}
|
|
439
439
|
$cohortMatches = $diskReadable -and $runtime.dsh_version -eq $expectedDshVersion
|
|
440
|
+
# Read through PSObject: StrictMode is on, so a response that simply omits the
|
|
441
|
+
# property would throw and be swallowed as "not ready" rather than being
|
|
442
|
+
# reported absent.
|
|
443
|
+
$runtimeId = $null
|
|
444
|
+
if ($null -ne $runtime) {
|
|
445
|
+
$runtimeIdProperty = $runtime.PSObject.Properties['runtime_id']
|
|
446
|
+
if ($null -ne $runtimeIdProperty -and $null -ne $runtimeIdProperty.Value) { $runtimeId = [string] $runtimeIdProperty.Value }
|
|
447
|
+
}
|
|
440
448
|
if ($response.ok -eq $true -and $version -and $cohortMatches) {
|
|
441
|
-
return [pscustomobject]@{ Ready = $true; Version = [string] $version; Error = $null }
|
|
449
|
+
return [pscustomobject]@{ Ready = $true; Version = [string] $version; RuntimeId = $runtimeId; Error = $null }
|
|
442
450
|
}
|
|
443
451
|
if (-not $diskReadable) {
|
|
444
452
|
return [pscustomobject]@{ Ready = $false; Version = $null; Error = 'disk runtime manifest unreadable; cannot prove cohort identity' }
|
|
@@ -551,6 +559,92 @@ function Get-FreshSupervisorHeartbeat {
|
|
|
551
559
|
return $null
|
|
552
560
|
}
|
|
553
561
|
|
|
562
|
+
# ---- Persisted ownership of the live Hub -----------------------------------
|
|
563
|
+
# A watcher can exit while the Hub it started keeps serving. Nothing else on the
|
|
564
|
+
# machine can tell a later watcher that such a listener is Crew's: port health
|
|
565
|
+
# says a process answers, not whose it is, and adopting on health alone would put
|
|
566
|
+
# a stranger's listener within reach of Stop-OwnedListener. So the identity is
|
|
567
|
+
# written down when it is established, and re-proven field by field — PID, start
|
|
568
|
+
# time, port, profile, Crew home and live runtime_id — before any later watcher
|
|
569
|
+
# adopts it. A record that merely exists is not authority; it may name a Hub from
|
|
570
|
+
# a previous cohort, another profile, or a PID the system has since recycled.
|
|
571
|
+
|
|
572
|
+
$crewOwnedServiceFile = Join-Path $crewSupervisorRoot 'owned-service.json'
|
|
573
|
+
|
|
574
|
+
function Write-OwnedServiceRecord {
|
|
575
|
+
param([pscustomobject] $Service, [string] $RuntimeId = $null)
|
|
576
|
+
if (-not $Service.CrewOwned -or -not $Service.RootPid -or -not $Service.RootStartedAtUtcTicks) { return }
|
|
577
|
+
$record = @{
|
|
578
|
+
schema_version = 1
|
|
579
|
+
profile = [string] $Service.Profile
|
|
580
|
+
home = [string] $Service.Home
|
|
581
|
+
port = [int] $Service.Port
|
|
582
|
+
root_pid = [int] $Service.RootPid
|
|
583
|
+
root_started_at_utc_ticks = [long] $Service.RootStartedAtUtcTicks
|
|
584
|
+
listener_pid = if ($Service.ListenerPid) { [int] $Service.ListenerPid } else { $null }
|
|
585
|
+
listener_started_at_utc_ticks = if ($Service.ListenerStartedAtUtcTicks) { [long] $Service.ListenerStartedAtUtcTicks } else { $null }
|
|
586
|
+
runtime_id = $RuntimeId
|
|
587
|
+
recorded_at = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
|
|
588
|
+
} | ConvertTo-Json -Compress
|
|
589
|
+
try {
|
|
590
|
+
if (-not (Test-Path -LiteralPath $crewSupervisorRoot -PathType Container)) { New-Item -ItemType Directory -Path $crewSupervisorRoot -Force | Out-Null }
|
|
591
|
+
$temp = Join-Path $crewSupervisorRoot ("owned-service.{0}.tmp" -f $PID)
|
|
592
|
+
Write-Utf8NoBom -Path $temp -Content $record
|
|
593
|
+
Move-Item -LiteralPath $temp -Destination $crewOwnedServiceFile -Force
|
|
594
|
+
} catch { /* ownership record is best-effort, like the heartbeat */ }
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function Clear-OwnedServiceRecord {
|
|
598
|
+
try {
|
|
599
|
+
if (Test-Path -LiteralPath $crewOwnedServiceFile -PathType Leaf) { Remove-Item -LiteralPath $crewOwnedServiceFile -Force }
|
|
600
|
+
} catch { }
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function Get-OwnedServiceRecord {
|
|
604
|
+
try {
|
|
605
|
+
if (-not (Test-Path -LiteralPath $crewOwnedServiceFile -PathType Leaf)) { return $null }
|
|
606
|
+
$record = Get-Content -LiteralPath $crewOwnedServiceFile -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
|
|
607
|
+
if ($record.schema_version -ne 1) { return $null }
|
|
608
|
+
return $record
|
|
609
|
+
} catch {
|
|
610
|
+
return $null
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function Restore-OwnedServiceRecord {
|
|
615
|
+
param([pscustomobject] $Service, [pscustomobject] $Health = $null)
|
|
616
|
+
if (-not $Service.CrewOwned) { return $false }
|
|
617
|
+
$record = Get-OwnedServiceRecord
|
|
618
|
+
if (-not $record) { return $false }
|
|
619
|
+
if ([string] $record.profile -ne [string] $Service.Profile) { return $false }
|
|
620
|
+
if ([string] $record.home -ne [string] $Service.Home) { return $false }
|
|
621
|
+
if ([int] $record.port -ne [int] $Service.Port) { return $false }
|
|
622
|
+
if (-not $record.root_pid -or -not $record.root_started_at_utc_ticks) { return $false }
|
|
623
|
+
if (-not $record.listener_pid -or -not $record.listener_started_at_utc_ticks) { return $false }
|
|
624
|
+
# Without this, an interrupted write could leave a record whose listener half is
|
|
625
|
+
# simply absent, and "absent" must never read as "the Hub this record names".
|
|
626
|
+
if ([string]::IsNullOrWhiteSpace([string] $record.runtime_id)) { return $false }
|
|
627
|
+
try {
|
|
628
|
+
$processes = @(Get-CimInstance Win32_Process -ErrorAction Stop)
|
|
629
|
+
} catch {
|
|
630
|
+
return $false
|
|
631
|
+
}
|
|
632
|
+
# Start times come from Get-Process, since Win32_Process carries none: the table
|
|
633
|
+
# only has to establish that the PID exists before the ticks are compared.
|
|
634
|
+
if (-not (Test-TrackedProcessIdentity -ProcessId ([int] $record.root_pid) -ExpectedStartTicks ([long] $record.root_started_at_utc_ticks) -ProcessTable $processes)) { return $false }
|
|
635
|
+
if (-not (Test-TrackedProcessIdentity -ProcessId ([int] $record.listener_pid) -ExpectedStartTicks ([long] $record.listener_started_at_utc_ticks) -ProcessTable $processes)) { return $false }
|
|
636
|
+
$port = Get-PortState $Service.Port
|
|
637
|
+
if ($port.State -ne 'occupied' -or -not $port.Pid -or [int] $port.Pid -ne [int] $record.listener_pid) { return $false }
|
|
638
|
+
$live = if ($Health) { $Health } else { Get-HealthState $Service }
|
|
639
|
+
if (-not $live.Ready) { return $false }
|
|
640
|
+
if ([string] $live.RuntimeId -ne [string] $record.runtime_id) { return $false }
|
|
641
|
+
$Service.RootPid = [int] $record.root_pid
|
|
642
|
+
$Service.RootStartedAtUtcTicks = [long] $record.root_started_at_utc_ticks
|
|
643
|
+
$Service.ListenerPid = [int] $record.listener_pid
|
|
644
|
+
$Service.ListenerStartedAtUtcTicks = [long] $record.listener_started_at_utc_ticks
|
|
645
|
+
return $true
|
|
646
|
+
}
|
|
647
|
+
|
|
554
648
|
function Ensure-CrewSupervisorRunning {
|
|
555
649
|
param([int] $TimeoutSeconds = 90)
|
|
556
650
|
$watcher = $null
|
|
@@ -967,7 +1061,7 @@ function Get-TrackedProcessTree {
|
|
|
967
1061
|
}
|
|
968
1062
|
|
|
969
1063
|
function Set-TrackedListenerIdentity {
|
|
970
|
-
param([pscustomobject] $Service)
|
|
1064
|
+
param([pscustomobject] $Service, [string] $RuntimeId = $null)
|
|
971
1065
|
$port = Get-PortState $Service.Port
|
|
972
1066
|
if ($port.State -ne 'occupied' -or -not $port.Pid) { return $false }
|
|
973
1067
|
$tree = @(Get-TrackedProcessTree -Service $Service)
|
|
@@ -976,6 +1070,7 @@ function Set-TrackedListenerIdentity {
|
|
|
976
1070
|
$listener = Get-Process -Id $port.Pid -ErrorAction Stop
|
|
977
1071
|
$Service.ListenerPid = [int] $port.Pid
|
|
978
1072
|
$Service.ListenerStartedAtUtcTicks = $listener.StartTime.ToUniversalTime().Ticks
|
|
1073
|
+
Write-OwnedServiceRecord -Service $Service -RuntimeId $RuntimeId
|
|
979
1074
|
return $true
|
|
980
1075
|
} catch {
|
|
981
1076
|
return $false
|
|
@@ -1057,6 +1152,10 @@ function Start-CrewService {
|
|
|
1057
1152
|
$Service.ListenerStartedAtUtcTicks = $null
|
|
1058
1153
|
$Service.ConsecutiveFailures = 0
|
|
1059
1154
|
$Service.State = 'starting'
|
|
1155
|
+
# Recorded before the Hub is healthy on purpose: the record is written again
|
|
1156
|
+
# with the listener identity once it is, and only that later write is
|
|
1157
|
+
# adoptable, because this one carries no runtime_id to prove itself against.
|
|
1158
|
+
Write-OwnedServiceRecord -Service $Service
|
|
1060
1159
|
Write-LaunchLog ('Started {0} on port {1}; PID={2}; stdout={3}; stderr={4}' -f $Service.Profile, $Service.Port, $process.Id, $stdout, $stderr)
|
|
1061
1160
|
} finally {
|
|
1062
1161
|
$env:DSH_HOME = $previousHome
|
|
@@ -1070,7 +1169,7 @@ function Wait-CrewServices {
|
|
|
1070
1169
|
$health = Get-HealthState $service
|
|
1071
1170
|
$service.LastError = $health.Error
|
|
1072
1171
|
if ($health.Ready) {
|
|
1073
|
-
if ($service.CrewOwned -and -not $service.ListenerPid -and -not (Set-TrackedListenerIdentity -Service $service)) {
|
|
1172
|
+
if ($service.CrewOwned -and -not $service.ListenerPid -and -not (Set-TrackedListenerIdentity -Service $service -RuntimeId $health.RuntimeId)) {
|
|
1074
1173
|
throw ('{0} is healthy on {1}, but this supervisor cannot prove process ownership.' -f $service.Name, $service.Port)
|
|
1075
1174
|
}
|
|
1076
1175
|
if ($service.CrewOwned -and -not (Test-CrewServiceOwnership -Service $service)) {
|
|
@@ -1118,7 +1217,16 @@ function Ensure-CrewServices {
|
|
|
1118
1217
|
$health = Get-HealthState $service
|
|
1119
1218
|
if ($health.Ready) {
|
|
1120
1219
|
$wasReady = $service.State -eq 'ready'
|
|
1121
|
-
if ($service.CrewOwned -and -not $service.ListenerPid
|
|
1220
|
+
if ($service.CrewOwned -and -not $service.ListenerPid) {
|
|
1221
|
+
# A watcher that exited leaves its Hub serving with nothing in memory to
|
|
1222
|
+
# say whose it is. Re-prove the persisted identity before touching it;
|
|
1223
|
+
# this is the only path by which a later watcher may adopt a live Hub,
|
|
1224
|
+
# and it never adopts on port health alone.
|
|
1225
|
+
if (Restore-OwnedServiceRecord -Service $service -Health $health) {
|
|
1226
|
+
Write-LaunchLog ('{0} on {1} recovered from the persisted ownership record; listener PID={2}.' -f $service.Name, $service.Port, $service.ListenerPid)
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
if ($service.CrewOwned -and -not $service.ListenerPid -and -not (Set-TrackedListenerIdentity -Service $service -RuntimeId $health.RuntimeId)) {
|
|
1122
1230
|
throw ('{0} is healthy on {1}, but this supervisor cannot prove process ownership.' -f $service.Name, $service.Port)
|
|
1123
1231
|
}
|
|
1124
1232
|
if ($service.CrewOwned -and -not (Test-CrewServiceOwnership -Service $service)) {
|
|
@@ -1157,6 +1265,7 @@ function Ensure-CrewServices {
|
|
|
1157
1265
|
$service.ListenerPid = $null
|
|
1158
1266
|
$service.ListenerStartedAtUtcTicks = $null
|
|
1159
1267
|
$service.ConsecutiveFailures = 0
|
|
1268
|
+
Clear-OwnedServiceRecord
|
|
1160
1269
|
$port = Get-PortState $service.Port
|
|
1161
1270
|
}
|
|
1162
1271
|
}
|