dsh-crate-web 0.1.1 → 0.1.2-preview.3
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 +12 -289
- package/README.zh-CN.md +2 -303
- package/THIRD-PARTY-LICENSE.txt +21 -0
- package/cordis.patch.yml +1 -6
- package/core/dsh_pack/_version.py +1 -0
- package/core/dsh_pack/bundle.py +74 -0
- package/core/dsh_pack/cli.py +81 -18
- package/core/dsh_pack/desktop_verify.py +304 -0
- package/core/dsh_pack/diagnostics.py +1170 -0
- package/core/dsh_pack/errors.py +16 -1
- package/core/dsh_pack/export.py +86 -103
- package/core/dsh_pack/export_plan.py +96 -0
- package/core/dsh_pack/identity.py +25 -0
- package/core/dsh_pack/importer.py +468 -217
- package/core/dsh_pack/network.py +5 -4
- package/core/dsh_pack/pack.py +14 -2
- package/core/dsh_pack/portability.py +392 -0
- package/core/dsh_pack/preflight.py +63 -16
- package/core/dsh_pack/troubleshooting.py +94 -0
- package/core/dsh_pack/user_cli.py +16 -0
- package/core/dsh_pack/validation.py +64 -4
- package/core/dsh_pack/verify.py +343 -194
- package/core/dsh_pack/windows_verify.py +177 -0
- package/desktop-patch-helper.mjs +140 -0
- package/lib/client.js +292 -102
- package/lib/index.d.ts +7 -1
- package/lib/index.js +1121 -229
- package/package.json +44 -7
- package/skills/dsh-crate-troubleshooting/SKILL.md +172 -0
- package/skills/dsh-crate-troubleshooting/compatibility.json +11 -0
- package/skills/dsh-crate-troubleshooting/reference/diagnostics.generated.md +96 -0
- package/skills/dsh-crate-troubleshooting/reference/repair-boundaries.md +60 -0
- package/skills/dsh-crate-troubleshooting/reference/stages.generated.md +22 -0
- package/verify-desktop.mjs +314 -0
package/core/dsh_pack/verify.py
CHANGED
|
@@ -9,9 +9,12 @@ or model interaction. Without an adapter, runtime checks are UNTESTED.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import json
|
|
12
|
+
import errno
|
|
13
|
+
import ipaddress
|
|
12
14
|
import os
|
|
13
15
|
import posixpath
|
|
14
16
|
import re
|
|
17
|
+
import socket
|
|
15
18
|
import subprocess
|
|
16
19
|
import threading
|
|
17
20
|
import time
|
|
@@ -19,7 +22,12 @@ import urllib.error
|
|
|
19
22
|
import urllib.parse
|
|
20
23
|
import urllib.request
|
|
21
24
|
from .errors import _default_checks
|
|
22
|
-
from
|
|
25
|
+
from .errors import DshPackError
|
|
26
|
+
from .bundle import check_patch_declaration, read_bundle_patches
|
|
27
|
+
from .identity import expected_install_version
|
|
28
|
+
from .pack import sha256_bytes
|
|
29
|
+
from .preflight import _artifact_package_identity
|
|
30
|
+
from dataclasses import dataclass, field, replace
|
|
23
31
|
from pathlib import Path
|
|
24
32
|
from typing import Any, Mapping, Protocol, Sequence
|
|
25
33
|
|
|
@@ -142,7 +150,9 @@ def _runtime_diagnostic(
|
|
|
142
150
|
can_continue: bool = False,
|
|
143
151
|
) -> dict[str, Any]:
|
|
144
152
|
"""Structured diagnostic attached to a failed runtime VerifyStep."""
|
|
153
|
+
from .diagnostics import envelope_fields
|
|
145
154
|
return {
|
|
155
|
+
**envelope_fields(operation="verify", status="FAIL"),
|
|
146
156
|
"code": code,
|
|
147
157
|
"stage": stage,
|
|
148
158
|
"severity": "BLOCKER",
|
|
@@ -159,7 +169,16 @@ def _runtime_diagnostic(
|
|
|
159
169
|
|
|
160
170
|
def _normalise_observation(name: str, value: VerifyStep | Mapping[str, Any], *, required: bool) -> VerifyStep:
|
|
161
171
|
if isinstance(value, VerifyStep):
|
|
162
|
-
|
|
172
|
+
# 步骤身份和必测要求由 Core 决定;适配器标签不能绕过
|
|
173
|
+
# 前置失败,也不能弱化最终状态。保留适配器的原始证据。
|
|
174
|
+
return VerifyStep(
|
|
175
|
+
name=name,
|
|
176
|
+
status=value.status,
|
|
177
|
+
message=value.message,
|
|
178
|
+
required=required,
|
|
179
|
+
evidence=value.evidence,
|
|
180
|
+
diagnostic=value.diagnostic,
|
|
181
|
+
)
|
|
163
182
|
if not isinstance(value, Mapping):
|
|
164
183
|
return _step(
|
|
165
184
|
name,
|
|
@@ -181,7 +200,7 @@ def _normalise_observation(name: str, value: VerifyStep | Mapping[str, Any], *,
|
|
|
181
200
|
name,
|
|
182
201
|
status,
|
|
183
202
|
str(value.get("message", name)),
|
|
184
|
-
required=
|
|
203
|
+
required=required,
|
|
185
204
|
evidence=value.get("evidence") if isinstance(value.get("evidence"), Mapping) else {},
|
|
186
205
|
)
|
|
187
206
|
|
|
@@ -223,85 +242,6 @@ def _profile_bundles(profile_manifest: Mapping[str, Any]) -> tuple[list[str] | N
|
|
|
223
242
|
return list(bundles), None
|
|
224
243
|
|
|
225
244
|
|
|
226
|
-
def _resolve_installed_package_dir(anchor_dir: Path, name: str) -> Path | None:
|
|
227
|
-
"""Node-style node_modules lookup from an anchor directory.
|
|
228
|
-
|
|
229
|
-
Mirrors DSH ``packageDirFromAnchor`` (installation anchor, then the profile
|
|
230
|
-
directory): probes the profile-local ``node_modules``, the shared
|
|
231
|
-
``profiles/node_modules`` layer, home-level ``node_modules``, then each
|
|
232
|
-
ancestor. This matches what the Cordis Loader would import from the same
|
|
233
|
-
anchor.
|
|
234
|
-
"""
|
|
235
|
-
parts = Path(*name.split("/"))
|
|
236
|
-
current = anchor_dir
|
|
237
|
-
while True:
|
|
238
|
-
candidate = current / "node_modules" / parts
|
|
239
|
-
if (candidate / "package.json").is_file():
|
|
240
|
-
return candidate
|
|
241
|
-
parent = current.parent
|
|
242
|
-
if parent == current:
|
|
243
|
-
return None
|
|
244
|
-
current = parent
|
|
245
|
-
return None
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
def _internal_composition_issues(
|
|
249
|
-
profile_path: Path,
|
|
250
|
-
dependencies: Mapping[str, Any],
|
|
251
|
-
bundles: Sequence[str],
|
|
252
|
-
) -> list[dict[str, Any]]:
|
|
253
|
-
"""Verify a Profile against its own manifest when no Pack metadata exists.
|
|
254
|
-
|
|
255
|
-
Used when the Crate Import metadata directory for this Profile is absent
|
|
256
|
-
(the Profile was not created by a Crate Import, or the metadata was
|
|
257
|
-
removed). This can never claim a Pack round-trip: it only checks that
|
|
258
|
-
declared Bundles are installed with their patch files, and that declared
|
|
259
|
-
dependencies resolve. The caller marks the Pack-comparison dimension as
|
|
260
|
-
UNTESTED.
|
|
261
|
-
"""
|
|
262
|
-
issues: list[dict[str, Any]] = []
|
|
263
|
-
declared = dict(dependencies) if isinstance(dependencies, Mapping) else {}
|
|
264
|
-
for name in bundles:
|
|
265
|
-
package_dir = _resolve_installed_package_dir(profile_path, name)
|
|
266
|
-
if package_dir is None:
|
|
267
|
-
issues.append({"code": "BUNDLE_NOT_INSTALLED", "bundle": name, "error": "not resolvable from the profile or shared node_modules layers"})
|
|
268
|
-
continue
|
|
269
|
-
installed, error = _json_object(package_dir / "package.json")
|
|
270
|
-
if installed is None:
|
|
271
|
-
issues.append({"code": "BUNDLE_NOT_INSTALLED", "bundle": name, "error": error})
|
|
272
|
-
continue
|
|
273
|
-
dsh = installed.get("dsh", {})
|
|
274
|
-
if not isinstance(dsh, Mapping):
|
|
275
|
-
dsh = {}
|
|
276
|
-
bundle = dsh.get("bundle", {})
|
|
277
|
-
patch = bundle.get("patch") if isinstance(bundle, Mapping) else None
|
|
278
|
-
normalized_patch = posixpath.normpath(patch.replace("\\", "/")) if isinstance(patch, str) else ""
|
|
279
|
-
safe_patch = (
|
|
280
|
-
normalized_patch not in {"", ".", ".."}
|
|
281
|
-
and not normalized_patch.startswith("../")
|
|
282
|
-
and not normalized_patch.startswith("/")
|
|
283
|
-
and ".." not in normalized_patch.split("/")
|
|
284
|
-
)
|
|
285
|
-
patch_path = package_dir / Path(*normalized_patch.split("/")) if safe_patch else None
|
|
286
|
-
if not isinstance(patch, str) or not patch.strip() or patch_path is None or not patch_path.is_file():
|
|
287
|
-
issues.append({
|
|
288
|
-
"code": "BUNDLE_PATCH_MISSING",
|
|
289
|
-
"bundle": name,
|
|
290
|
-
"patch": patch,
|
|
291
|
-
"path": str(patch_path) if patch_path else None,
|
|
292
|
-
})
|
|
293
|
-
for name, spec in declared.items():
|
|
294
|
-
package_dir = _resolve_installed_package_dir(profile_path, name)
|
|
295
|
-
if package_dir is None:
|
|
296
|
-
issues.append({
|
|
297
|
-
"code": "DEPENDENCY_NOT_INSTALLED",
|
|
298
|
-
"plugin": name,
|
|
299
|
-
"spec": spec,
|
|
300
|
-
"error": "not resolvable from the profile or shared node_modules layers",
|
|
301
|
-
})
|
|
302
|
-
return issues
|
|
303
|
-
|
|
304
|
-
|
|
305
245
|
def _composition_step(options: "VerifyOptions") -> VerifyStep:
|
|
306
246
|
profile_path = options.dsh_home / "profiles" / options.profile_name
|
|
307
247
|
metadata_path = options.dsh_home / ".dsh-pack" / "imports" / options.profile_name
|
|
@@ -318,50 +258,9 @@ def _composition_step(options: "VerifyOptions") -> VerifyStep:
|
|
|
318
258
|
if bundle_error or bundles is None:
|
|
319
259
|
return _step("composition", "FAIL", bundle_error or "invalid Bundle composition", evidence={"path": str(package_path)})
|
|
320
260
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
metadata_present = metadata_path.is_dir() and any(
|
|
325
|
-
candidate.is_file() for candidate in (manifest_file, lock_file, prepared_file)
|
|
326
|
-
)
|
|
327
|
-
if not metadata_present:
|
|
328
|
-
# This Profile was not created by a Crate Import, or the Import
|
|
329
|
-
# metadata was removed. The Pack-comparison dimension is UNTESTED:
|
|
330
|
-
# only an internal consistency check against the Profile's own
|
|
331
|
-
# manifest is possible, never a claimed round-trip.
|
|
332
|
-
dependencies = profile_manifest.get("dependencies", {})
|
|
333
|
-
if not isinstance(dependencies, Mapping):
|
|
334
|
-
return _step(
|
|
335
|
-
"composition",
|
|
336
|
-
"FAIL",
|
|
337
|
-
"Profile dependencies is not an object",
|
|
338
|
-
evidence={"path": str(package_path)},
|
|
339
|
-
)
|
|
340
|
-
internal_issues = _internal_composition_issues(profile_path, dependencies, bundles)
|
|
341
|
-
if internal_issues:
|
|
342
|
-
return _step(
|
|
343
|
-
"composition",
|
|
344
|
-
"FAIL",
|
|
345
|
-
"Crate Import metadata is absent and the Profile fails its internal consistency check",
|
|
346
|
-
evidence={
|
|
347
|
-
"metadataPath": str(metadata_path),
|
|
348
|
-
"packComparison": "UNTESTED",
|
|
349
|
-
"issues": internal_issues,
|
|
350
|
-
},
|
|
351
|
-
)
|
|
352
|
-
return _step(
|
|
353
|
-
"composition",
|
|
354
|
-
"DEGRADED",
|
|
355
|
-
"Crate Import metadata is absent; only internal consistency was checked, Pack round-trip UNTESTED",
|
|
356
|
-
evidence={
|
|
357
|
-
"metadataPath": str(metadata_path),
|
|
358
|
-
"profileBundles": bundles,
|
|
359
|
-
"packComparison": "UNTESTED",
|
|
360
|
-
},
|
|
361
|
-
)
|
|
362
|
-
metadata_manifest, manifest_error = _json_object(manifest_file)
|
|
363
|
-
lock, lock_error = _json_object(lock_file)
|
|
364
|
-
prepared, prepared_error = _json_object(prepared_file)
|
|
261
|
+
metadata_manifest, manifest_error = _json_object(metadata_path / "manifest.json")
|
|
262
|
+
lock, lock_error = _json_object(metadata_path / "plugins.lock.json")
|
|
263
|
+
prepared, prepared_error = _json_object(metadata_path / "prepared.json")
|
|
365
264
|
if manifest_error or lock_error or prepared_error or metadata_manifest is None or lock is None or prepared is None:
|
|
366
265
|
return _step(
|
|
367
266
|
"composition",
|
|
@@ -392,6 +291,40 @@ def _composition_step(options: "VerifyOptions") -> VerifyStep:
|
|
|
392
291
|
expected_bundles: list[str] = []
|
|
393
292
|
installed_rows: list[dict[str, Any]] = []
|
|
394
293
|
issues: list[dict[str, Any]] = []
|
|
294
|
+
installation = prepared.get('installation')
|
|
295
|
+
rebuilt = isinstance(installation, Mapping) and installation.get('resolver') == 'pnpm'
|
|
296
|
+
original_dependencies = None
|
|
297
|
+
if rebuilt:
|
|
298
|
+
try:
|
|
299
|
+
original_bytes = (profile_path / '.dsh-crate/original-package.json').read_bytes()
|
|
300
|
+
if sha256_bytes(original_bytes) != installation.get('originalManifestSha256'):
|
|
301
|
+
raise ValueError('original manifest hash mismatch')
|
|
302
|
+
if sha256_bytes((profile_path / 'pnpm-workspace.yaml').read_bytes()) != installation.get('workspaceSha256'):
|
|
303
|
+
raise ValueError('generated installation workspace hash mismatch')
|
|
304
|
+
adaptation = installation.get('profilePatchAdaptation')
|
|
305
|
+
if isinstance(adaptation, Mapping) and adaptation.get('changed') is True:
|
|
306
|
+
if adaptation.get('adapter') != 'official-desktop-composition-equivalence-v1':
|
|
307
|
+
raise ValueError('unsupported Profile patch adaptation identity')
|
|
308
|
+
if sha256_bytes((profile_path / '.dsh-crate/original-profile-config/cordis.patch.yml').read_bytes()) != adaptation.get('originalSha256'):
|
|
309
|
+
raise ValueError('original Profile patch hash mismatch')
|
|
310
|
+
if sha256_bytes((profile_path / 'cordis.patch.yml').read_bytes()) != adaptation.get('runtimeSha256'):
|
|
311
|
+
raise ValueError('adapted runtime Profile patch hash mismatch')
|
|
312
|
+
if not isinstance(adaptation.get('loaderTreeSha256'), str) or not re.fullmatch('[a-f0-9]{64}', adaptation['loaderTreeSha256']):
|
|
313
|
+
raise ValueError('official Loader equivalence evidence is invalid')
|
|
314
|
+
removed = adaptation.get('removedNames')
|
|
315
|
+
if not isinstance(removed, list) or not removed or any(not isinstance(row, Mapping) or set(row) != {'id', 'name'} or not all(isinstance(value, str) and value for value in row.values()) for row in removed):
|
|
316
|
+
raise ValueError('Profile patch adaptation record is invalid')
|
|
317
|
+
original_dependencies = json.loads(original_bytes).get('dependencies', {})
|
|
318
|
+
if not isinstance(original_dependencies, Mapping):
|
|
319
|
+
raise ValueError('original dependencies must be an object')
|
|
320
|
+
modules, error = _json_object(profile_path / 'node_modules/.modules.yaml')
|
|
321
|
+
if error or modules is None or modules.get('nodeLinker') != 'hoisted' or not (profile_path / 'pnpm-lock.yaml').is_file():
|
|
322
|
+
raise ValueError('pnpm installation records are incomplete')
|
|
323
|
+
store = modules.get('virtualStoreDir')
|
|
324
|
+
if not isinstance(store, str) or (profile_path / 'node_modules' / store).resolve() != (profile_path / 'node_modules/.pnpm').resolve():
|
|
325
|
+
raise ValueError('pnpm virtual store does not match the prepared Profile')
|
|
326
|
+
except (OSError, ValueError, TypeError) as error:
|
|
327
|
+
issues.append({'code': 'REBUILD_STATE_INVALID', 'message': str(error)})
|
|
395
328
|
for index, plugin in enumerate(raw_plugins):
|
|
396
329
|
name = plugin.get("name")
|
|
397
330
|
if not isinstance(name, str) or not name:
|
|
@@ -399,10 +332,35 @@ def _composition_step(options: "VerifyOptions") -> VerifyStep:
|
|
|
399
332
|
continue
|
|
400
333
|
requested = plugin.get("requested")
|
|
401
334
|
requested_specifier = requested.get("specifier") if isinstance(requested, Mapping) else None
|
|
402
|
-
|
|
403
|
-
resolved_version = resolved.get("version") if isinstance(resolved, Mapping) else None
|
|
335
|
+
resolved_version = expected_install_version(plugin)
|
|
404
336
|
declared_spec = dependencies.get(name)
|
|
405
|
-
|
|
337
|
+
installed, installed_error = _package_manifest(profile_path, name)
|
|
338
|
+
prepared_names = {row.get('name') for row in prepared.get('plugins', []) if isinstance(row, Mapping)}
|
|
339
|
+
skipped_optional = plugin.get('required') is not True and name not in prepared_names and installed is None and declared_spec is None
|
|
340
|
+
if rebuilt and not skipped_optional:
|
|
341
|
+
try:
|
|
342
|
+
requests = installation.get('requests', {})
|
|
343
|
+
spec = requests.get(name) if isinstance(requests, Mapping) else None
|
|
344
|
+
match = re.fullmatch(r'file:\./\.dsh-crate/artifacts/([0-9a-f]{64})\.tgz', spec or '')
|
|
345
|
+
if not match or declared_spec != spec:
|
|
346
|
+
raise ValueError('local artifact dependency mapping mismatch')
|
|
347
|
+
if original_dependencies is None or original_dependencies.get(name, 'unknown') != requested_specifier:
|
|
348
|
+
raise ValueError('original Profile SPEC does not match plugins.lock')
|
|
349
|
+
artifact = plugin.get('artifact', {})
|
|
350
|
+
if artifact.get('mode') == 'embedded' and artifact.get('sha256') != match[1]:
|
|
351
|
+
raise ValueError('embedded artifact mapping differs from plugins.lock hash')
|
|
352
|
+
path = profile_path / '.dsh-crate/artifacts' / (match[1] + '.tgz')
|
|
353
|
+
if path.is_symlink() or path.resolve().parent != (profile_path / '.dsh-crate/artifacts').resolve():
|
|
354
|
+
raise ValueError('artifact path escapes the prepared Profile')
|
|
355
|
+
content = path.read_bytes()
|
|
356
|
+
if sha256_bytes(content) != match[1]:
|
|
357
|
+
raise ValueError('local rebuild artifact hash mismatch')
|
|
358
|
+
identity = _artifact_package_identity(content)
|
|
359
|
+
if identity != {'name': name, 'version': resolved_version}:
|
|
360
|
+
raise ValueError('local rebuild artifact identity mismatch')
|
|
361
|
+
except (OSError, ValueError, TypeError) as error:
|
|
362
|
+
issues.append({'code': 'REBUILD_ARTIFACT_INVALID', 'plugin': name, 'message': str(error)})
|
|
363
|
+
elif declared_spec is not None and declared_spec != requested_specifier:
|
|
406
364
|
issues.append({
|
|
407
365
|
"code": "PROFILE_SPEC_MISMATCH",
|
|
408
366
|
"plugin": name,
|
|
@@ -413,24 +371,20 @@ def _composition_step(options: "VerifyOptions") -> VerifyStep:
|
|
|
413
371
|
enabled = isinstance(bundle, Mapping) and bundle.get("enabled") is True
|
|
414
372
|
if enabled:
|
|
415
373
|
expected_bundles.append(name)
|
|
374
|
+
if enabled and not skipped_optional:
|
|
416
375
|
patch = bundle.get("patch")
|
|
417
376
|
package_path_for_plugin = profile_path / "node_modules" / Path(*name.split("/"))
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
and ".." not in normalized_patch.split("/")
|
|
424
|
-
)
|
|
425
|
-
patch_path = package_path_for_plugin / Path(*normalized_patch.split("/")) if safe_patch else None
|
|
426
|
-
if not isinstance(patch, str) or not patch.strip() or patch_path is None or not patch_path.is_file():
|
|
377
|
+
try:
|
|
378
|
+
read_bundle_patches(package_path_for_plugin, patch)
|
|
379
|
+
if installed is not None:
|
|
380
|
+
check_patch_declaration(installed, patch)
|
|
381
|
+
except DshPackError as error:
|
|
427
382
|
issues.append({
|
|
428
|
-
"code": "
|
|
383
|
+
"code": "BUNDLE_PATCH_INVALID",
|
|
429
384
|
"plugin": name,
|
|
430
385
|
"patch": patch,
|
|
431
|
-
"
|
|
386
|
+
"error": str(error),
|
|
432
387
|
})
|
|
433
|
-
installed, installed_error = _package_manifest(profile_path, name)
|
|
434
388
|
installed_row = {
|
|
435
389
|
"name": name,
|
|
436
390
|
"required": plugin.get("required") is True,
|
|
@@ -512,6 +466,7 @@ class VerifyResult:
|
|
|
512
466
|
available_secret_names: frozenset[str]
|
|
513
467
|
steps: tuple[VerifyStep, ...]
|
|
514
468
|
plugin_contracts: tuple[SmokeTestContract, ...]
|
|
469
|
+
business_status: str | None = None
|
|
515
470
|
|
|
516
471
|
def __post_init__(self) -> None:
|
|
517
472
|
if self.status not in VERIFY_STATUSES:
|
|
@@ -526,6 +481,8 @@ class VerifyResult:
|
|
|
526
481
|
]
|
|
527
482
|
return {
|
|
528
483
|
"status": self.status,
|
|
484
|
+
"businessStatus": self.business_status or self.status,
|
|
485
|
+
"cleanup": step_map.get("cleanup"),
|
|
529
486
|
"profile": {
|
|
530
487
|
"name": self.profile_name,
|
|
531
488
|
"dshHome": str(self.dsh_home),
|
|
@@ -567,6 +524,73 @@ def _overall_status(steps: Sequence[VerifyStep]) -> str:
|
|
|
567
524
|
return "PASS"
|
|
568
525
|
|
|
569
526
|
|
|
527
|
+
def _cleanup_status(evidence: Mapping[str, Any]) -> str:
|
|
528
|
+
"""缺失证据不能当作释放成功;此前失败的清理也不能被末次成功遮蔽。"""
|
|
529
|
+
attempts = evidence.get("attempts", [])
|
|
530
|
+
if isinstance(attempts, list) and any(
|
|
531
|
+
isinstance(item, Mapping) and _cleanup_status(item) == "FAIL" for item in attempts
|
|
532
|
+
):
|
|
533
|
+
return "FAIL"
|
|
534
|
+
endpoints = evidence.get("endpoints", [])
|
|
535
|
+
endpoints = endpoints if isinstance(endpoints, list) else []
|
|
536
|
+
if (evidence.get("status") == "FAIL" or evidence.get("errors") or evidence.get("processExited") is False
|
|
537
|
+
or evidence.get("portReleased") is False
|
|
538
|
+
or any(isinstance(item, Mapping) and item.get("released") is False for item in endpoints)):
|
|
539
|
+
return "FAIL"
|
|
540
|
+
if evidence.get("processExited") is not True:
|
|
541
|
+
return "UNTESTED"
|
|
542
|
+
if evidence.get("portApplicable") is not False and evidence.get("portReleased") is not True:
|
|
543
|
+
return "UNTESTED"
|
|
544
|
+
if evidence.get("portApplicable") is not False and (
|
|
545
|
+
not isinstance(evidence.get("portEvidence"), Mapping)
|
|
546
|
+
or evidence["portEvidence"].get("checked") is not True
|
|
547
|
+
):
|
|
548
|
+
return "UNTESTED"
|
|
549
|
+
if evidence.get("endpointsApplicable") is not False and (
|
|
550
|
+
not endpoints or any(not isinstance(item, Mapping) or item.get("checked") is not True
|
|
551
|
+
or item.get("released") is not True for item in endpoints)
|
|
552
|
+
):
|
|
553
|
+
return "UNTESTED"
|
|
554
|
+
if isinstance(attempts, list) and any(
|
|
555
|
+
not isinstance(item, Mapping) or _cleanup_status(item) != "PASS" for item in attempts
|
|
556
|
+
):
|
|
557
|
+
return "UNTESTED"
|
|
558
|
+
return "PASS"
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _cleanup_step(adapter: VerifyAdapter | None) -> VerifyStep:
|
|
562
|
+
close = getattr(adapter, "close", None)
|
|
563
|
+
if not callable(close):
|
|
564
|
+
return _step("cleanup", "UNTESTED", "未提供可核查的最终清理接口",
|
|
565
|
+
evidence={"processExited": None, "portReleased": None, "endpoints": [],
|
|
566
|
+
"reason": "VerifyAdapter has no close implementation"})
|
|
567
|
+
try:
|
|
568
|
+
value = close()
|
|
569
|
+
except Exception as error:
|
|
570
|
+
evidence = {"processExited": None, "portReleased": None, "endpoints": [],
|
|
571
|
+
"error": str(error), "exception": type(error).__name__}
|
|
572
|
+
return _step("cleanup", "FAIL", "最终清理抛出异常;原业务步骤和诊断已保留", evidence=evidence,
|
|
573
|
+
diagnostic=_runtime_diagnostic(
|
|
574
|
+
code="VERIFY_CLEANUP_EXCEPTION", stage="cleanup", message="最终清理抛出异常",
|
|
575
|
+
evidence=evidence, item="cleanup", expected="清理完成并返回资源释放证据",
|
|
576
|
+
impact="无法确认验证资源已释放;请检查 cleanup 证据中的进程和端点。"))
|
|
577
|
+
evidence = dict(value) if isinstance(value, Mapping) else {
|
|
578
|
+
"processExited": None, "portReleased": None, "endpoints": [],
|
|
579
|
+
"reason": "close returned no resource evidence"}
|
|
580
|
+
status = _cleanup_status(evidence)
|
|
581
|
+
diagnostic = None
|
|
582
|
+
if status != "PASS":
|
|
583
|
+
diagnostic = _runtime_diagnostic(
|
|
584
|
+
code="VERIFY_CLEANUP_FAILED" if status == "FAIL" else "VERIFY_CLEANUP_UNVERIFIED",
|
|
585
|
+
stage="cleanup", message="最终清理失败" if status == "FAIL" else "最终清理证据不足",
|
|
586
|
+
evidence=evidence, item="cleanup", expected="已观察进程、端口和端点均有释放证据",
|
|
587
|
+
impact="原业务步骤和诊断已保留;资源释放状态请单独检查。")
|
|
588
|
+
return _step("cleanup", status,
|
|
589
|
+
{"PASS": "已核查所记录资源的释放", "FAIL": "清理失败;原业务步骤和诊断已保留",
|
|
590
|
+
"UNTESTED": "清理证据不足;未将未知状态判为成功"}[status],
|
|
591
|
+
evidence=evidence, diagnostic=diagnostic)
|
|
592
|
+
|
|
593
|
+
|
|
570
594
|
def _loader_composition_failure(composition: VerifyStep, boot: VerifyStep) -> dict[str, Any] | None:
|
|
571
595
|
"""Classify a real DSH Loader activation failure as composition failure.
|
|
572
596
|
|
|
@@ -593,22 +617,38 @@ def _loader_composition_failure(composition: VerifyStep, boot: VerifyStep) -> di
|
|
|
593
617
|
if not any(marker in lowered for marker in markers):
|
|
594
618
|
return None
|
|
595
619
|
|
|
596
|
-
|
|
620
|
+
# 只接受失败行明确命名的条目;全局启动日志和安装顺序不能证明归属。
|
|
621
|
+
# 运行条目可能是未直接列在 Bundle 清单中的包或 subpath,必须保留原名。
|
|
622
|
+
target_pattern = re.compile(
|
|
623
|
+
r"(?P<marker>plugin tree failed to load\s*:\s*|failed to apply loader entry\s+|did not activate\s+)"
|
|
624
|
+
r"[`\"']?(?P<target>(?:@[\w.-]+/)?[\w.-]+(?:/[\w.-]+)*)[`\"']?(?=\s*(?::|$))",
|
|
625
|
+
re.IGNORECASE,
|
|
626
|
+
)
|
|
597
627
|
installed_plugins = composition.evidence.get("installedPlugins", [])
|
|
598
|
-
|
|
599
|
-
for row in installed_plugins
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
628
|
+
installed_names = {
|
|
629
|
+
row["name"] for row in installed_plugins
|
|
630
|
+
if isinstance(row, Mapping) and isinstance(row.get("name"), str) and row["name"]
|
|
631
|
+
} if isinstance(installed_plugins, list) else set()
|
|
632
|
+
failed_targets: set[str] = set()
|
|
633
|
+
for line in combined.splitlines():
|
|
634
|
+
if not any(marker in line.lower() for marker in markers):
|
|
635
|
+
continue
|
|
636
|
+
for match in target_pattern.finditer(line):
|
|
637
|
+
target = match.group("target")
|
|
638
|
+
if target.lower() in {"error", "typeerror", "rangeerror", "exception"}:
|
|
604
639
|
continue
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
640
|
+
# 泛化的等待失败文案不是 Loader 证据;只有明确包名才可归属。
|
|
641
|
+
if match.group("marker").lower().startswith("did not activate"):
|
|
642
|
+
if not (target.startswith("@") or any(target == name or target.startswith(name + "/") for name in installed_names)):
|
|
643
|
+
continue
|
|
644
|
+
failed_targets.add(target)
|
|
645
|
+
for name in installed_names:
|
|
646
|
+
if re.search(r"(?<![\w@./-])" + re.escape(name) + r"\s+did not activate\b", line, re.IGNORECASE):
|
|
647
|
+
failed_targets.add(name)
|
|
648
|
+
has_loader_failure = any(marker in lowered for marker in markers if marker != "did not activate")
|
|
649
|
+
if not has_loader_failure and not failed_targets:
|
|
650
|
+
return None
|
|
651
|
+
failed_bundle = next(iter(failed_targets)) if len(failed_targets) == 1 else None
|
|
612
652
|
|
|
613
653
|
observed = "Loader activation failed" if "loader activation failed" in lowered else "DSH Loader activation failed"
|
|
614
654
|
return {
|
|
@@ -694,6 +734,15 @@ def _plugin_step(
|
|
|
694
734
|
)
|
|
695
735
|
|
|
696
736
|
|
|
737
|
+
def _runtime_blocker(steps: list[VerifyStep]) -> str | None:
|
|
738
|
+
"""Preserve the first failure; never probe or restart past missing startup evidence."""
|
|
739
|
+
runtime_names = {name for name, _ in RUNTIME_STEPS}
|
|
740
|
+
prerequisites = {"boot", "surface_ready", "restart", "restart_surface_ready"}
|
|
741
|
+
return next((step.name for step in steps if step.name in runtime_names
|
|
742
|
+
and (step.status == "FAIL" or (step.name in prerequisites
|
|
743
|
+
and step.status not in {"PASS", "DEGRADED"}))), None)
|
|
744
|
+
|
|
745
|
+
|
|
697
746
|
def verify_profile(options: VerifyOptions) -> VerifyResult:
|
|
698
747
|
"""Verify one prepared Profile without changing its files."""
|
|
699
748
|
|
|
@@ -707,8 +756,10 @@ def verify_profile(options: VerifyOptions) -> VerifyResult:
|
|
|
707
756
|
available_secret_names=options.available_secret_names,
|
|
708
757
|
steps=(_step("composition", "FAIL", "DSH_HOME does not exist", evidence={"dshHome": str(options.dsh_home)}),),
|
|
709
758
|
plugin_contracts=options.plugin_contracts,
|
|
759
|
+
business_status="FAIL",
|
|
710
760
|
)
|
|
711
|
-
|
|
761
|
+
cleanup = _cleanup_step(options.adapter)
|
|
762
|
+
return replace(result, steps=(*result.steps, cleanup))
|
|
712
763
|
|
|
713
764
|
composition = _composition_step(options)
|
|
714
765
|
steps: list[VerifyStep] = [composition]
|
|
@@ -724,10 +775,7 @@ def verify_profile(options: VerifyOptions) -> VerifyResult:
|
|
|
724
775
|
}
|
|
725
776
|
|
|
726
777
|
for name, label in RUNTIME_STEPS:
|
|
727
|
-
blocking_runtime_step =
|
|
728
|
-
(step.name for step in steps if step.name in {"boot", "restart"} and step.status == "FAIL"),
|
|
729
|
-
None,
|
|
730
|
-
)
|
|
778
|
+
blocking_runtime_step = _runtime_blocker(steps)
|
|
731
779
|
if composition.status == "FAIL":
|
|
732
780
|
current = _step(
|
|
733
781
|
name,
|
|
@@ -739,7 +787,7 @@ def verify_profile(options: VerifyOptions) -> VerifyResult:
|
|
|
739
787
|
current = _step(
|
|
740
788
|
name,
|
|
741
789
|
"UNTESTED",
|
|
742
|
-
f"{label} was not run because an earlier runtime
|
|
790
|
+
f"{label} was not run because an earlier runtime prerequisite did not pass",
|
|
743
791
|
evidence={"blockedBy": blocking_runtime_step},
|
|
744
792
|
)
|
|
745
793
|
else:
|
|
@@ -748,10 +796,49 @@ def verify_profile(options: VerifyOptions) -> VerifyResult:
|
|
|
748
796
|
steps.append(current)
|
|
749
797
|
previous[current.name] = current
|
|
750
798
|
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
799
|
+
# The imported lock, rather than the caller's contract list, defines which
|
|
800
|
+
# plugin capabilities must be accounted for. A missing or empty contract
|
|
801
|
+
# is explicit missing evidence, never an implicit successful smoke test.
|
|
802
|
+
imported_plugins = composition.evidence.get("installedPlugins", [])
|
|
803
|
+
imported_required = {
|
|
804
|
+
row["name"]: row.get("required") is True
|
|
805
|
+
for row in imported_plugins
|
|
806
|
+
if isinstance(row, Mapping) and isinstance(row.get("name"), str)
|
|
807
|
+
} if isinstance(imported_plugins, list) else {}
|
|
808
|
+
contracts = [
|
|
809
|
+
SmokeTestContract(
|
|
810
|
+
plugin=contract.plugin,
|
|
811
|
+
tests=tuple(test for test in contract.tests if isinstance(test, str) and test.strip()),
|
|
812
|
+
required=contract.required or imported_required.get(contract.plugin, False),
|
|
813
|
+
)
|
|
814
|
+
for contract in options.plugin_contracts
|
|
815
|
+
]
|
|
816
|
+
covered_plugins = {contract.plugin for contract in contracts}
|
|
817
|
+
contracts.extend(
|
|
818
|
+
SmokeTestContract(plugin=name, tests=(), required=required)
|
|
819
|
+
for name, required in imported_required.items()
|
|
820
|
+
if name not in covered_plugins
|
|
821
|
+
)
|
|
822
|
+
blocking_plugin_step = ("composition" if composition.status not in {"PASS", "DEGRADED"}
|
|
823
|
+
else _runtime_blocker(steps))
|
|
824
|
+
for contract in contracts:
|
|
825
|
+
for test_name in contract.tests or (None,):
|
|
826
|
+
step_name = f"plugin:{contract.plugin}:{test_name or 'untested'}"
|
|
827
|
+
if blocking_plugin_step is not None:
|
|
828
|
+
current = _step(
|
|
829
|
+
step_name, "UNTESTED", "Plugin smoke test was not run because runtime prerequisites did not pass",
|
|
830
|
+
required=contract.required,
|
|
831
|
+
evidence={"plugin": contract.plugin, "test": test_name, "blockedBy": blocking_plugin_step},
|
|
832
|
+
)
|
|
833
|
+
elif test_name is None:
|
|
834
|
+
current = _step(
|
|
835
|
+
step_name, "UNTESTED", "Imported plugin has no non-empty dedicated smoke-test contract",
|
|
836
|
+
required=contract.required,
|
|
837
|
+
evidence={"plugin": contract.plugin, "test": None, "reason": "missing smoke-test contract"},
|
|
838
|
+
)
|
|
839
|
+
else:
|
|
840
|
+
context = VerifyContext(previous_steps=dict(previous), **context_base)
|
|
841
|
+
current = _plugin_step(options.adapter, contract, test_name, context)
|
|
755
842
|
steps.append(current)
|
|
756
843
|
previous[current.name] = current
|
|
757
844
|
|
|
@@ -773,6 +860,8 @@ def verify_profile(options: VerifyOptions) -> VerifyResult:
|
|
|
773
860
|
)
|
|
774
861
|
steps[0] = composition
|
|
775
862
|
|
|
863
|
+
business_status = _overall_status(steps)
|
|
864
|
+
steps.append(_cleanup_step(options.adapter))
|
|
776
865
|
result = VerifyResult(
|
|
777
866
|
status=_overall_status(steps),
|
|
778
867
|
dsh_home=options.dsh_home,
|
|
@@ -781,16 +870,9 @@ def verify_profile(options: VerifyOptions) -> VerifyResult:
|
|
|
781
870
|
current_environment=options.current_environment,
|
|
782
871
|
available_secret_names=options.available_secret_names,
|
|
783
872
|
steps=tuple(steps),
|
|
784
|
-
plugin_contracts=
|
|
873
|
+
plugin_contracts=tuple(contracts),
|
|
874
|
+
business_status=business_status,
|
|
785
875
|
)
|
|
786
|
-
close = getattr(options.adapter, "close", None)
|
|
787
|
-
if callable(close):
|
|
788
|
-
try:
|
|
789
|
-
close()
|
|
790
|
-
except Exception:
|
|
791
|
-
# Runtime cleanup evidence belongs to the adapter; do not replace
|
|
792
|
-
# the actual verification result with a cleanup exception.
|
|
793
|
-
pass
|
|
794
876
|
return result
|
|
795
877
|
|
|
796
878
|
|
|
@@ -826,6 +908,8 @@ class SubprocessVerifyAdapter:
|
|
|
826
908
|
self._threads: list[threading.Thread] = []
|
|
827
909
|
self._url: str | None = None
|
|
828
910
|
self._last_headless: dict[str, Any] | None = None
|
|
911
|
+
self._cleanup_attempts: list[dict[str, Any]] = []
|
|
912
|
+
self._last_cleanup: dict[str, Any] | None = None
|
|
829
913
|
|
|
830
914
|
def _timeout(self) -> float:
|
|
831
915
|
value = self.config.get("timeoutSeconds", 60)
|
|
@@ -849,13 +933,16 @@ class SubprocessVerifyAdapter:
|
|
|
849
933
|
|
|
850
934
|
def _environment(self, context: VerifyContext) -> dict[str, str]:
|
|
851
935
|
env = dict(os.environ) if self.config.get("inheritEnvironment", True) is not False else {}
|
|
852
|
-
env["DSH_HOME"] = str(context.dsh_home)
|
|
853
|
-
env["DSH_PROFILE"] = context.profile_name
|
|
854
936
|
if self._url:
|
|
855
937
|
env["DSH_WEB_URL"] = self._url
|
|
856
938
|
extra = self.config.get("environment", {})
|
|
857
939
|
if isinstance(extra, Mapping):
|
|
858
940
|
env.update({str(key): str(value) for key, value in extra.items()})
|
|
941
|
+
# Windows environment names are case-insensitive. Remove all inherited
|
|
942
|
+
# and configured aliases before assigning the actual isolated target.
|
|
943
|
+
env = {key: value for key, value in env.items() if key.upper() not in {"DSH_HOME", "DSH_PROFILE"}}
|
|
944
|
+
env["DSH_HOME"] = str(context.dsh_home)
|
|
945
|
+
env["DSH_PROFILE"] = context.profile_name
|
|
859
946
|
return env
|
|
860
947
|
|
|
861
948
|
@staticmethod
|
|
@@ -1176,6 +1263,9 @@ class SubprocessVerifyAdapter:
|
|
|
1176
1263
|
)
|
|
1177
1264
|
except (OSError, subprocess.TimeoutExpired) as error:
|
|
1178
1265
|
return {"checked": False, "portOwnerPid": None, "portBelongsToProcess": False, "ownershipError": str(error)}
|
|
1266
|
+
if completed.returncode != 0:
|
|
1267
|
+
return {"checked": False, "portOwnerPid": None, "portBelongsToProcess": False,
|
|
1268
|
+
"ownershipError": "netstat failed", "exitCode": completed.returncode}
|
|
1179
1269
|
port_owner: int | None = None
|
|
1180
1270
|
for line in completed.stdout.splitlines():
|
|
1181
1271
|
fields = line.split()
|
|
@@ -1205,7 +1295,10 @@ class SubprocessVerifyAdapter:
|
|
|
1205
1295
|
restarted = self._run_headless(context, step) if self.mode == "headless" else self._rename(self._spawn_web(context), step)
|
|
1206
1296
|
evidence = dict(restarted.evidence)
|
|
1207
1297
|
evidence["stoppedProcess"] = stopped
|
|
1208
|
-
|
|
1298
|
+
# 已完成的headless命令没有Web端口;不适用不是“释放未知”。
|
|
1299
|
+
no_web_port = (self.mode == "headless" and stopped.get("processExited") is True
|
|
1300
|
+
and stopped.get("portApplicable") is False)
|
|
1301
|
+
if restarted.status == "PASS" and stopped.get("portReleased") is not True and not no_web_port:
|
|
1209
1302
|
return _step(
|
|
1210
1303
|
step,
|
|
1211
1304
|
"FAIL",
|
|
@@ -1283,47 +1376,103 @@ class SubprocessVerifyAdapter:
|
|
|
1283
1376
|
),
|
|
1284
1377
|
)
|
|
1285
1378
|
|
|
1379
|
+
@staticmethod
|
|
1380
|
+
def _endpoint_release(url: str) -> dict[str, Any]:
|
|
1381
|
+
"""只核查已观察的本地 TCP 端点;拒绝连接才证明监听已释放。"""
|
|
1382
|
+
evidence: dict[str, Any] = {"checked": False, "released": None, "transport": "tcp"}
|
|
1383
|
+
try:
|
|
1384
|
+
parsed = urllib.parse.urlparse(url)
|
|
1385
|
+
address = ipaddress.ip_address(parsed.hostname or "")
|
|
1386
|
+
if not address.is_loopback or parsed.port is None:
|
|
1387
|
+
raise ValueError("only explicit loopback endpoints with a port are checked")
|
|
1388
|
+
evidence.update({"url": f"{parsed.scheme}://{'[' + str(address) + ']' if address.version == 6 else address}:{parsed.port}",
|
|
1389
|
+
"host": str(address), "port": parsed.port})
|
|
1390
|
+
with socket.socket(socket.AF_INET6 if address.version == 6 else socket.AF_INET, socket.SOCK_STREAM) as client:
|
|
1391
|
+
client.settimeout(0.3)
|
|
1392
|
+
code = client.connect_ex((str(address), parsed.port))
|
|
1393
|
+
evidence["socketError"] = code
|
|
1394
|
+
if code == 0:
|
|
1395
|
+
evidence.update(checked=True, released=False)
|
|
1396
|
+
elif code in {errno.ECONNREFUSED, 10061}:
|
|
1397
|
+
evidence.update(checked=True, released=True)
|
|
1398
|
+
else:
|
|
1399
|
+
evidence["reason"] = "连接结果不能证明端点已释放"
|
|
1400
|
+
except (OSError, ValueError) as error:
|
|
1401
|
+
evidence["reason"] = str(error)
|
|
1402
|
+
return evidence
|
|
1403
|
+
|
|
1286
1404
|
def close(self) -> dict[str, Any]:
|
|
1287
1405
|
process = self.process
|
|
1288
1406
|
url = self._url
|
|
1289
1407
|
if process is None:
|
|
1290
|
-
|
|
1408
|
+
if self._last_cleanup is not None:
|
|
1409
|
+
return self._last_cleanup
|
|
1410
|
+
completed = self._last_headless is not None
|
|
1411
|
+
return {"pid": None, "url": None, "processExited": True if completed else None,
|
|
1412
|
+
"exitCode": self._last_headless.get("exitCode") if completed else None,
|
|
1413
|
+
"portReleased": None, "portApplicable": False, "endpointsApplicable": False,
|
|
1414
|
+
"endpoints": [], "attempts": [],
|
|
1415
|
+
"reason": "headless subprocess.run completed" if completed else "no runtime process was observed"}
|
|
1291
1416
|
pid = process.pid
|
|
1292
|
-
|
|
1293
|
-
|
|
1417
|
+
actions: list[str] = []
|
|
1418
|
+
errors: list[dict[str, str]] = []
|
|
1419
|
+
exit_code = None
|
|
1420
|
+
try:
|
|
1421
|
+
if process.poll() is None:
|
|
1422
|
+
actions.append("terminate")
|
|
1423
|
+
process.terminate()
|
|
1424
|
+
try:
|
|
1425
|
+
process.wait(timeout=5)
|
|
1426
|
+
except subprocess.TimeoutExpired:
|
|
1427
|
+
actions.append("kill")
|
|
1428
|
+
process.kill()
|
|
1429
|
+
process.wait(timeout=5)
|
|
1430
|
+
exit_code = process.poll()
|
|
1431
|
+
except Exception as error:
|
|
1432
|
+
errors.append({"operation": "stop-process", "error": str(error), "exception": type(error).__name__})
|
|
1294
1433
|
try:
|
|
1295
|
-
process.
|
|
1296
|
-
except
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
port_released = True
|
|
1434
|
+
exit_code = process.poll()
|
|
1435
|
+
except Exception as poll_error:
|
|
1436
|
+
errors.append({"operation": "poll-process", "error": str(poll_error), "exception": type(poll_error).__name__})
|
|
1437
|
+
port_evidence: dict[str, Any] = {"checked": False, "reason": "no observed Web URL"}
|
|
1438
|
+
port_released: bool | None = None
|
|
1439
|
+
endpoints: list[dict[str, Any]] = []
|
|
1302
1440
|
if self.mode == "web" and url:
|
|
1303
1441
|
deadline = time.monotonic() + 2.0
|
|
1304
1442
|
while True:
|
|
1305
|
-
|
|
1443
|
+
try:
|
|
1444
|
+
port_evidence = self._port_owner_for(url, pid)
|
|
1445
|
+
except Exception as error:
|
|
1446
|
+
port_evidence = {"checked": False, "ownershipError": str(error)}
|
|
1447
|
+
endpoints = [{"url": url, **self._endpoint_release(url)}]
|
|
1306
1448
|
if port_evidence.get("checked") is not True:
|
|
1307
|
-
port_released =
|
|
1449
|
+
port_released = None
|
|
1308
1450
|
break
|
|
1309
|
-
|
|
1310
|
-
|
|
1451
|
+
port_released = port_evidence.get("portOwnerPid") is None
|
|
1452
|
+
if port_released and endpoints[0].get("released") is True:
|
|
1311
1453
|
break
|
|
1312
1454
|
if time.monotonic() >= deadline:
|
|
1313
|
-
port_released = False
|
|
1314
1455
|
break
|
|
1315
1456
|
time.sleep(0.05)
|
|
1457
|
+
for thread in self._threads:
|
|
1458
|
+
thread.join(timeout=1)
|
|
1316
1459
|
evidence = {
|
|
1317
1460
|
"pid": pid,
|
|
1318
1461
|
"url": url,
|
|
1319
1462
|
"exitCode": exit_code,
|
|
1320
|
-
"processExited":
|
|
1463
|
+
"processExited": True if exit_code is not None else (False if not errors else None),
|
|
1321
1464
|
"portReleased": port_released,
|
|
1322
1465
|
"portEvidence": port_evidence,
|
|
1466
|
+
"portApplicable": self.mode == "web", "endpointsApplicable": self.mode == "web",
|
|
1467
|
+
"endpoints": endpoints,
|
|
1468
|
+
"endpointCoverage": "observed-web-url-only; Inspector/PTY/unknown descendants are not inferred",
|
|
1469
|
+
"actions": actions, "errors": errors,
|
|
1323
1470
|
}
|
|
1471
|
+
self._cleanup_attempts.append(evidence)
|
|
1472
|
+
self._last_cleanup = {**evidence, "attempts": list(self._cleanup_attempts)}
|
|
1324
1473
|
self.process = None
|
|
1325
1474
|
self._url = None
|
|
1326
|
-
return
|
|
1475
|
+
return self._last_cleanup
|
|
1327
1476
|
|
|
1328
1477
|
|
|
1329
1478
|
def load_smoke_contracts(path: str | os.PathLike[str]) -> tuple[SmokeTestContract, ...]:
|