@logictan/dsh-config-manager 0.1.60
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/LICENSE +21 -0
- package/README.md +105 -0
- package/cordis.patch.yml +21 -0
- package/lib/adapters/agent-instructions.d.ts +13 -0
- package/lib/adapters/agent-instructions.js +46 -0
- package/lib/adapters/agent-presets.d.ts +13 -0
- package/lib/adapters/agent-presets.js +14 -0
- package/lib/adapters/credentials.d.ts +37 -0
- package/lib/adapters/credentials.js +122 -0
- package/lib/adapters/file-collection.d.ts +15 -0
- package/lib/adapters/file-collection.js +127 -0
- package/lib/adapters/index.d.ts +57 -0
- package/lib/adapters/index.js +55 -0
- package/lib/adapters/link-report.d.ts +16 -0
- package/lib/adapters/link-report.js +47 -0
- package/lib/adapters/mcp.d.ts +28 -0
- package/lib/adapters/mcp.js +178 -0
- package/lib/adapters/plugin-files.d.ts +18 -0
- package/lib/adapters/plugin-files.js +142 -0
- package/lib/adapters/plugins.d.ts +41 -0
- package/lib/adapters/plugins.js +603 -0
- package/lib/adapters/pnpm-workspace.d.ts +26 -0
- package/lib/adapters/pnpm-workspace.js +147 -0
- package/lib/adapters/prompts.d.ts +30 -0
- package/lib/adapters/prompts.js +231 -0
- package/lib/adapters/providers.d.ts +34 -0
- package/lib/adapters/providers.js +155 -0
- package/lib/adapters/self.d.ts +17 -0
- package/lib/adapters/self.js +71 -0
- package/lib/adapters/sessions.d.ts +14 -0
- package/lib/adapters/sessions.js +15 -0
- package/lib/adapters/settings.d.ts +28 -0
- package/lib/adapters/settings.js +165 -0
- package/lib/adapters/skills.d.ts +13 -0
- package/lib/adapters/skills.js +14 -0
- package/lib/adapters/test-helpers.d.ts +125 -0
- package/lib/adapters/test-helpers.js +236 -0
- package/lib/adapters/ui.d.ts +27 -0
- package/lib/adapters/ui.js +89 -0
- package/lib/adapters/workspaces.d.ts +13 -0
- package/lib/adapters/workspaces.js +106 -0
- package/lib/client.d.ts +1026 -0
- package/lib/client.js +10047 -0
- package/lib/core/analyzer.d.ts +99 -0
- package/lib/core/analyzer.js +934 -0
- package/lib/core/backup.d.ts +138 -0
- package/lib/core/backup.js +513 -0
- package/lib/core/boot-rescue.d.ts +146 -0
- package/lib/core/boot-rescue.js +618 -0
- package/lib/core/cache-cleaner.d.ts +31 -0
- package/lib/core/cache-cleaner.js +95 -0
- package/lib/core/config-lifecycle.d.ts +180 -0
- package/lib/core/config-lifecycle.js +481 -0
- package/lib/core/config-snapshot.d.ts +135 -0
- package/lib/core/config-snapshot.js +424 -0
- package/lib/core/config-state.d.ts +70 -0
- package/lib/core/config-state.js +177 -0
- package/lib/core/consult-source.d.ts +58 -0
- package/lib/core/consult-source.js +260 -0
- package/lib/core/crash-report.d.ts +84 -0
- package/lib/core/crash-report.js +317 -0
- package/lib/core/exporter.d.ts +68 -0
- package/lib/core/exporter.js +375 -0
- package/lib/core/ghost-sweep.d.ts +19 -0
- package/lib/core/ghost-sweep.js +52 -0
- package/lib/core/importer.d.ts +60 -0
- package/lib/core/importer.js +29 -0
- package/lib/core/index.d.ts +14 -0
- package/lib/core/index.js +14 -0
- package/lib/core/journal.d.ts +197 -0
- package/lib/core/journal.js +418 -0
- package/lib/core/local-plugin-host.d.ts +35 -0
- package/lib/core/local-plugin-host.js +120 -0
- package/lib/core/local-plugin-pack.d.ts +135 -0
- package/lib/core/local-plugin-pack.js +278 -0
- package/lib/core/messages.d.ts +310 -0
- package/lib/core/messages.js +621 -0
- package/lib/core/migration-consult.d.ts +117 -0
- package/lib/core/migration-consult.js +363 -0
- package/lib/core/migration-history.d.ts +153 -0
- package/lib/core/migration-history.js +411 -0
- package/lib/core/model-tools.d.ts +50 -0
- package/lib/core/model-tools.js +148 -0
- package/lib/core/msg-types.d.ts +8 -0
- package/lib/core/msg-types.js +6 -0
- package/lib/core/phase3-child-crash.d.ts +1 -0
- package/lib/core/phase3-child-crash.js +87 -0
- package/lib/core/phase3-host.d.ts +138 -0
- package/lib/core/phase3-host.js +359 -0
- package/lib/core/phase3-prod-child.d.ts +1 -0
- package/lib/core/phase3-prod-child.js +35 -0
- package/lib/core/phase4-crash-child.d.ts +1 -0
- package/lib/core/phase4-crash-child.js +103 -0
- package/lib/core/plugin-cli.d.ts +150 -0
- package/lib/core/plugin-cli.js +497 -0
- package/lib/core/reconcile.d.ts +120 -0
- package/lib/core/reconcile.js +389 -0
- package/lib/core/recovery-orchestrator.d.ts +94 -0
- package/lib/core/recovery-orchestrator.js +327 -0
- package/lib/core/restore.d.ts +162 -0
- package/lib/core/restore.js +703 -0
- package/lib/core/rollback.d.ts +12 -0
- package/lib/core/rollback.js +145 -0
- package/lib/core/run-registry.d.ts +85 -0
- package/lib/core/run-registry.js +157 -0
- package/lib/core/startup-barrier.d.ts +53 -0
- package/lib/core/startup-barrier.js +77 -0
- package/lib/core/types.d.ts +515 -0
- package/lib/core/types.js +27 -0
- package/lib/core/undo.d.ts +73 -0
- package/lib/core/undo.js +87 -0
- package/lib/core/validator.d.ts +18 -0
- package/lib/core/validator.js +77 -0
- package/lib/core/verify-recovery.d.ts +21 -0
- package/lib/core/verify-recovery.js +256 -0
- package/lib/core/watcher.d.ts +101 -0
- package/lib/core/watcher.js +219 -0
- package/lib/index.d.ts +238 -0
- package/lib/index.js +2764 -0
- package/lib/migrations/index.d.ts +29 -0
- package/lib/migrations/index.js +56 -0
- package/lib/migrations/v1-to-v2.d.ts +9 -0
- package/lib/migrations/v1-to-v2.js +10 -0
- package/lib/profiles/index.d.ts +4 -0
- package/lib/profiles/index.js +5 -0
- package/lib/profiles/profile-manager.d.ts +106 -0
- package/lib/profiles/profile-manager.js +543 -0
- package/lib/schema/config.d.ts +28 -0
- package/lib/schema/config.js +165 -0
- package/lib/schema/index.d.ts +24 -0
- package/lib/schema/index.js +26 -0
- package/lib/schema/manifest.d.ts +28 -0
- package/lib/schema/manifest.js +109 -0
- package/lib/schema/tombstones.d.ts +46 -0
- package/lib/schema/tombstones.js +64 -0
- package/lib/schema/types.d.ts +232 -0
- package/lib/schema/types.js +8 -0
- package/lib/schema/versions.d.ts +27 -0
- package/lib/schema/versions.js +50 -0
- package/lib/security/encryption.d.ts +85 -0
- package/lib/security/encryption.js +279 -0
- package/lib/security/index.d.ts +16 -0
- package/lib/security/index.js +17 -0
- package/lib/security/integrity.d.ts +30 -0
- package/lib/security/integrity.js +78 -0
- package/lib/security/redaction.d.ts +26 -0
- package/lib/security/redaction.js +95 -0
- package/lib/security/secret-scanner.d.ts +86 -0
- package/lib/security/secret-scanner.js +404 -0
- package/lib/security/vault.d.ts +59 -0
- package/lib/security/vault.js +151 -0
- package/lib/security/zip-security.d.ts +27 -0
- package/lib/security/zip-security.js +154 -0
- package/lib/sync/autosync-config.d.ts +39 -0
- package/lib/sync/autosync-config.js +151 -0
- package/lib/sync/autosync-scheduler.d.ts +139 -0
- package/lib/sync/autosync-scheduler.js +564 -0
- package/lib/sync/fs.d.ts +18 -0
- package/lib/sync/fs.js +49 -0
- package/lib/sync/git/git-transport.d.ts +100 -0
- package/lib/sync/git/git-transport.js +437 -0
- package/lib/sync/github-auth.d.ts +131 -0
- package/lib/sync/github-auth.js +264 -0
- package/lib/sync/layout.d.ts +43 -0
- package/lib/sync/layout.js +203 -0
- package/lib/sync/snapshot-json.d.ts +9 -0
- package/lib/sync/snapshot-json.js +107 -0
- package/lib/sync/sync-config.d.ts +81 -0
- package/lib/sync/sync-config.js +281 -0
- package/lib/sync/sync-engine.d.ts +269 -0
- package/lib/sync/sync-engine.js +591 -0
- package/lib/sync/sync-history.d.ts +42 -0
- package/lib/sync/sync-history.js +78 -0
- package/lib/sync/sync-selection.d.ts +29 -0
- package/lib/sync/sync-selection.js +127 -0
- package/lib/sync/sync-session.d.ts +40 -0
- package/lib/sync/sync-session.js +54 -0
- package/lib/sync/sync-state.d.ts +37 -0
- package/lib/sync/sync-state.js +108 -0
- package/lib/sync/transport.d.ts +75 -0
- package/lib/sync/transport.js +55 -0
- package/lib/sync/ui-prefs.d.ts +34 -0
- package/lib/sync/ui-prefs.js +109 -0
- package/lib/sync/webdav/webdav-transport.d.ts +77 -0
- package/lib/sync/webdav/webdav-transport.js +451 -0
- package/lib/ui/errors.d.ts +19 -0
- package/lib/ui/errors.js +81 -0
- package/lib/ui/export-flow.d.ts +82 -0
- package/lib/ui/export-flow.js +98 -0
- package/lib/ui/i18n.d.ts +289 -0
- package/lib/ui/i18n.js +581 -0
- package/lib/ui/migration-consult-view.d.ts +91 -0
- package/lib/ui/migration-consult-view.js +115 -0
- package/lib/ui/progress.d.ts +38 -0
- package/lib/ui/progress.js +87 -0
- package/lib/ui/report.d.ts +23 -0
- package/lib/ui/report.js +153 -0
- package/lib/ui/test-helpers.d.ts +69 -0
- package/lib/ui/test-helpers.js +154 -0
- package/lib/ui/types.d.ts +257 -0
- package/lib/ui/types.js +21 -0
- package/lib/utils/atomic-write.d.ts +88 -0
- package/lib/utils/atomic-write.js +399 -0
- package/lib/utils/env-lock.d.ts +393 -0
- package/lib/utils/env-lock.js +930 -0
- package/lib/utils/hashing.d.ts +17 -0
- package/lib/utils/hashing.js +41 -0
- package/lib/utils/json.d.ts +24 -0
- package/lib/utils/json.js +76 -0
- package/lib/utils/logger.d.ts +39 -0
- package/lib/utils/logger.js +82 -0
- package/lib/utils/paths.d.ts +42 -0
- package/lib/utils/paths.js +189 -0
- package/lib/utils/proxy.d.ts +69 -0
- package/lib/utils/proxy.js +455 -0
- package/lib/utils/recursive-walk.d.ts +24 -0
- package/lib/utils/recursive-walk.js +134 -0
- package/lib/utils/zip.d.ts +54 -0
- package/lib/utils/zip.js +285 -0
- package/package.json +188 -0
- package/src/adapters/agent-instructions.ts +48 -0
- package/src/adapters/agent-presets.ts +14 -0
- package/src/adapters/credentials.test.ts +90 -0
- package/src/adapters/credentials.ts +143 -0
- package/src/adapters/file-collection.ts +137 -0
- package/src/adapters/files.test.ts +353 -0
- package/src/adapters/index.ts +94 -0
- package/src/adapters/link-report.test.ts +65 -0
- package/src/adapters/link-report.ts +61 -0
- package/src/adapters/mcp.test.ts +96 -0
- package/src/adapters/mcp.ts +185 -0
- package/src/adapters/plugin-files.ts +146 -0
- package/src/adapters/plugins.test.ts +326 -0
- package/src/adapters/plugins.ts +615 -0
- package/src/adapters/pnpm-workspace.test.ts +87 -0
- package/src/adapters/pnpm-workspace.ts +150 -0
- package/src/adapters/prompts.test.ts +110 -0
- package/src/adapters/prompts.ts +230 -0
- package/src/adapters/providers.test.ts +126 -0
- package/src/adapters/providers.ts +173 -0
- package/src/adapters/roundtrip.test.ts +190 -0
- package/src/adapters/self.test.ts +92 -0
- package/src/adapters/self.ts +75 -0
- package/src/adapters/sessions.ts +15 -0
- package/src/adapters/settings.test.ts +100 -0
- package/src/adapters/settings.ts +187 -0
- package/src/adapters/skills.ts +14 -0
- package/src/adapters/test-helpers.ts +248 -0
- package/src/adapters/ui.test.ts +57 -0
- package/src/adapters/ui.ts +108 -0
- package/src/adapters/workspaces.test.ts +106 -0
- package/src/adapters/workspaces.ts +109 -0
- package/src/client/ConfigManagerSection.tsx +90 -0
- package/src/client/api.ts +66 -0
- package/src/client/client-types.ts +18 -0
- package/src/client/common/ErrorBanner.tsx +73 -0
- package/src/client/common/Icon.tsx +129 -0
- package/src/client/common/Modal.tsx +173 -0
- package/src/client/common/ToastViewport.tsx +73 -0
- package/src/client/common/toast-store.test.ts +192 -0
- package/src/client/common/toast-store.ts +181 -0
- package/src/client/common/toast-types.ts +7 -0
- package/src/client/common/ui.tsx +342 -0
- package/src/client/config-manager.module.css +2971 -0
- package/src/client/css-modules.d.ts +8 -0
- package/src/client/index.ts +65 -0
- package/src/client/locales.ts +56 -0
- package/src/client/lucide-icons.d.ts +38 -0
- package/src/client/run-store.test.ts +223 -0
- package/src/client/run-store.ts +372 -0
- package/src/client/sync/SyncConfirmView.tsx +379 -0
- package/src/client/sync/SyncHistoryView.test.ts +40 -0
- package/src/client/sync/SyncHistoryView.tsx +220 -0
- package/src/client/sync/SyncSettingsView.tsx +1535 -0
- package/src/client/sync/history-model.test.ts +194 -0
- package/src/client/sync/history-model.ts +195 -0
- package/src/client/sync/sync-api.test.ts +585 -0
- package/src/client/sync/sync-api.ts +543 -0
- package/src/client/sync/sync-locales.ts +416 -0
- package/src/client/sync/sync-push-preview.test.ts +52 -0
- package/src/client/sync/sync-view-v2.test.ts +213 -0
- package/src/client/sync/sync-view.test.ts +487 -0
- package/src/client/sync/sync-view.ts +759 -0
- package/src/core/analyzer.ts +1068 -0
- package/src/core/backup.ts +605 -0
- package/src/core/boot-rescue.test.ts +847 -0
- package/src/core/boot-rescue.ts +737 -0
- package/src/core/cache-cleaner.test.ts +169 -0
- package/src/core/cache-cleaner.ts +121 -0
- package/src/core/config-lifecycle.test.ts +727 -0
- package/src/core/config-lifecycle.ts +573 -0
- package/src/core/config-snapshot.test.ts +369 -0
- package/src/core/config-snapshot.ts +522 -0
- package/src/core/config-state.test.ts +381 -0
- package/src/core/config-state.ts +207 -0
- package/src/core/consult-source.test.ts +199 -0
- package/src/core/consult-source.ts +293 -0
- package/src/core/crash-report.test.ts +380 -0
- package/src/core/crash-report.ts +369 -0
- package/src/core/exporter.ts +417 -0
- package/src/core/ghost-sweep.test.ts +94 -0
- package/src/core/ghost-sweep.ts +67 -0
- package/src/core/importer.ts +83 -0
- package/src/core/index.ts +56 -0
- package/src/core/journal.test.ts +257 -0
- package/src/core/journal.ts +518 -0
- package/src/core/local-plugin-host.ts +152 -0
- package/src/core/local-plugin-pack.test.ts +359 -0
- package/src/core/local-plugin-pack.ts +363 -0
- package/src/core/messages.test.ts +54 -0
- package/src/core/messages.ts +664 -0
- package/src/core/migration-consult.test.ts +315 -0
- package/src/core/migration-consult.ts +494 -0
- package/src/core/migration-history.test.ts +319 -0
- package/src/core/migration-history.ts +484 -0
- package/src/core/model-tools.test.ts +190 -0
- package/src/core/model-tools.ts +208 -0
- package/src/core/msg-types.ts +10 -0
- package/src/core/phase3-child-crash.ts +95 -0
- package/src/core/phase3-host.ts +411 -0
- package/src/core/phase3-p1.test.ts +212 -0
- package/src/core/phase3-p2b-fingerprint.test.ts +189 -0
- package/src/core/phase3-prod-child.ts +36 -0
- package/src/core/phase3-production-integration.test.ts +286 -0
- package/src/core/phase4-crash-child.ts +101 -0
- package/src/core/phase4-crash-injection.test.ts +166 -0
- package/src/core/plugin-cli.fs.test.ts +184 -0
- package/src/core/plugin-cli.test.ts +137 -0
- package/src/core/plugin-cli.ts +563 -0
- package/src/core/reconcile.test.ts +392 -0
- package/src/core/reconcile.ts +498 -0
- package/src/core/recovery-orchestrator.test.ts +145 -0
- package/src/core/recovery-orchestrator.ts +366 -0
- package/src/core/restore-manage.test.ts +94 -0
- package/src/core/restore.ts +849 -0
- package/src/core/rollback.ts +151 -0
- package/src/core/run-progress.test.ts +237 -0
- package/src/core/run-registry.test.ts +232 -0
- package/src/core/run-registry.ts +209 -0
- package/src/core/smoke.test.ts +842 -0
- package/src/core/startup-barrier.ts +99 -0
- package/src/core/types.ts +542 -0
- package/src/core/undo.ts +116 -0
- package/src/core/validator.ts +84 -0
- package/src/core/verify-recovery.ts +265 -0
- package/src/core/watcher.test.ts +326 -0
- package/src/core/watcher.ts +263 -0
- package/src/index.facade.test.ts +265 -0
- package/src/index.sync.test.ts +217 -0
- package/src/index.ts +2975 -0
- package/src/migrations/index.ts +71 -0
- package/src/migrations/v1-to-v2.ts +17 -0
- package/src/profiles/index.ts +8 -0
- package/src/profiles/profile-manager.test.ts +282 -0
- package/src/profiles/profile-manager.ts +630 -0
- package/src/schema/config.ts +165 -0
- package/src/schema/index.ts +71 -0
- package/src/schema/manifest-schema.test.ts +519 -0
- package/src/schema/manifest.ts +124 -0
- package/src/schema/tombstones.test.ts +131 -0
- package/src/schema/tombstones.ts +89 -0
- package/src/schema/types.ts +245 -0
- package/src/schema/versions.ts +55 -0
- package/src/security/encryption.ts +335 -0
- package/src/security/index.ts +16 -0
- package/src/security/integrity.ts +102 -0
- package/src/security/redaction.ts +101 -0
- package/src/security/secret-scanner.ts +445 -0
- package/src/security/security.test.ts +1215 -0
- package/src/security/vault.test.ts +164 -0
- package/src/security/vault.ts +198 -0
- package/src/security/zip-security.ts +180 -0
- package/src/sync/autosync-config.test.ts +147 -0
- package/src/sync/autosync-config.ts +179 -0
- package/src/sync/autosync-scheduler.test.ts +490 -0
- package/src/sync/autosync-scheduler.ts +646 -0
- package/src/sync/fs.ts +48 -0
- package/src/sync/git/git-transport.test.ts +673 -0
- package/src/sync/git/git-transport.ts +491 -0
- package/src/sync/github-auth.test.ts +280 -0
- package/src/sync/github-auth.ts +356 -0
- package/src/sync/layout.test.ts +270 -0
- package/src/sync/layout.ts +222 -0
- package/src/sync/snapshot-json.test.ts +119 -0
- package/src/sync/snapshot-json.ts +128 -0
- package/src/sync/sync-config.test.ts +310 -0
- package/src/sync/sync-config.ts +299 -0
- package/src/sync/sync-engine.test.ts +1074 -0
- package/src/sync/sync-engine.ts +754 -0
- package/src/sync/sync-history.test.ts +116 -0
- package/src/sync/sync-history.ts +120 -0
- package/src/sync/sync-selection.test.ts +186 -0
- package/src/sync/sync-selection.ts +143 -0
- package/src/sync/sync-session.test.ts +143 -0
- package/src/sync/sync-session.ts +77 -0
- package/src/sync/sync-state.test.ts +164 -0
- package/src/sync/sync-state.ts +131 -0
- package/src/sync/transport.test.ts +111 -0
- package/src/sync/transport.ts +118 -0
- package/src/sync/ui-prefs.test.ts +152 -0
- package/src/sync/ui-prefs.ts +135 -0
- package/src/sync/webdav/webdav-transport.test.ts +871 -0
- package/src/sync/webdav/webdav-transport.ts +535 -0
- package/src/ui/errors.test.ts +47 -0
- package/src/ui/errors.ts +100 -0
- package/src/ui/export-flow.test.ts +87 -0
- package/src/ui/export-flow.ts +156 -0
- package/src/ui/i18n.ts +590 -0
- package/src/ui/migration-consult-view.test.ts +151 -0
- package/src/ui/migration-consult-view.ts +178 -0
- package/src/ui/progress.test.ts +47 -0
- package/src/ui/progress.ts +102 -0
- package/src/ui/report.test.ts +101 -0
- package/src/ui/report.ts +146 -0
- package/src/ui/test-helpers.ts +184 -0
- package/src/ui/types.ts +325 -0
- package/src/utils/atomic-write.test.ts +312 -0
- package/src/utils/atomic-write.ts +422 -0
- package/src/utils/env-lock.test.ts +999 -0
- package/src/utils/env-lock.ts +1086 -0
- package/src/utils/hashing.ts +53 -0
- package/src/utils/json.ts +77 -0
- package/src/utils/logger.ts +114 -0
- package/src/utils/paths.ts +176 -0
- package/src/utils/proxy.test.ts +465 -0
- package/src/utils/proxy.ts +493 -0
- package/src/utils/recursive-walk.test.ts +148 -0
- package/src/utils/recursive-walk.ts +145 -0
- package/src/utils/zip.ts +332 -0
|
@@ -0,0 +1,930 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 跨进程环境锁 primitive(Phase 2:Cross-process Lock)。
|
|
3
|
+
*
|
|
4
|
+
* 目标:防止多个 DSH 实例 / Web Host / CLI / AutoSync / Backup Scheduler / Model Tools
|
|
5
|
+
* 同时执行 destructive mutation。提供 **GLOBAL EXCLUSIVE MUTATION LOCK**。
|
|
6
|
+
*
|
|
7
|
+
* 设计基线:CROSS_PROCESS_LOCK_DESIGN.md Rev 3(BLOCKER 1–4 全部 CLOSED)。
|
|
8
|
+
* 关键不变量(NON-NEGOTIABLE,违反即破坏正确性):
|
|
9
|
+
* - 所有权获取 **必须** `open(lockPath, 'wx')` 独占创建语义;**禁止** exists→write、rm→recreate、
|
|
10
|
+
* 用 Phase 1 `atomicWriteFile(environment.lock)` 获取所有权。
|
|
11
|
+
* - `environment.lock` 是 **immutable ownership record**:创建后直到 release 不被 rename/replace。
|
|
12
|
+
* - heartbeat 走 **独立 sidecar** `environment.heartbeat.<instanceId>`,可安全用 Phase 1 `atomicWriteFile` 更新。
|
|
13
|
+
* - **operation-scoped** `MutationLockToken`:禁止 process-level reentrant(instanceId/handle/reenterCount 判嵌套)。
|
|
14
|
+
* - stale 检测只分类(LOCKED / STALE_LOCK_DETECTED / UNKNOWN_STATE),**绝不自动 unlink/takeover**。
|
|
15
|
+
* - recover 是独立显式动作(CLI `--recover-stale-lock`),用原子 rename 捕获 + 二次验证,二次验证失败不覆盖 successor。
|
|
16
|
+
* - destructive mutation 无 `--force`:必须成功 acquire,否则不得执行。
|
|
17
|
+
* - release 前校验 owner.instanceId === token.instanceId,不匹配不 unlink(ownership-lost)。
|
|
18
|
+
*
|
|
19
|
+
* 零 DSH 依赖(仅 node:fs / node:path / node:os / node:crypto + 复用 atomic-write.ts 的 atomicWriteFile)。
|
|
20
|
+
* CLI 离线引擎可复用。io / 时钟 / 进程身份探测可注入(对齐 Phase 1 AtomicIo 模式)。
|
|
21
|
+
*/
|
|
22
|
+
import fs from 'node:fs/promises';
|
|
23
|
+
import fssync from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import os from 'node:os';
|
|
26
|
+
import crypto from 'node:crypto';
|
|
27
|
+
import { atomicWriteFile } from './atomic-write.js';
|
|
28
|
+
// ---------- 常量 ----------
|
|
29
|
+
const WINDOWS = process.platform === 'win32';
|
|
30
|
+
/** 锁目录相对 dataDir */
|
|
31
|
+
export const LOCKS_DIR = 'locks';
|
|
32
|
+
/** 所有权记录文件名(immutable) */
|
|
33
|
+
export const OWNERSHIP_FILE = 'environment.lock';
|
|
34
|
+
/** heartbeat sidecar 前缀 + 文件名模板:environment.heartbeat.<instanceId> */
|
|
35
|
+
export const HEARTBEAT_PREFIX = 'environment.heartbeat.';
|
|
36
|
+
/** recovery 捕获文件名临时前缀 + 模板:environment.recovering.<recoveryInstanceId> */
|
|
37
|
+
export const RECOVERING_PREFIX = 'environment.recovering.';
|
|
38
|
+
/** lock schema 版本 */
|
|
39
|
+
export const LOCK_SCHEMA_VERSION = 1;
|
|
40
|
+
/** 默认心跳间隔 ms */
|
|
41
|
+
export const DEFAULT_HEARTBEAT_INTERVAL_MS = 1000;
|
|
42
|
+
/** 默认 stale 阈值 ms(≥ 10 × heartbeatInterval) */
|
|
43
|
+
export const DEFAULT_STALE_AFTER_MS = 10_000;
|
|
44
|
+
/** 默认 acquire 等待超时(等待活跃锁释放)ms;0 = 不等待直接返回 */
|
|
45
|
+
export const DEFAULT_ACQUIRE_TIMEOUT_MS = 0;
|
|
46
|
+
/** 「心跳长过期」下限 ms(issue #36)。实际阈值 = max(30 × staleAfterMs, 本值)。
|
|
47
|
+
* 用途:Windows/macOS 默认拿不到 OS process identity,Windows 又会复用 PID,于是
|
|
48
|
+
* 「心跳过期 + pid 存活」永远停在 UNKNOWN_STATE,用户只能手工删锁文件(issue #36 实测)。
|
|
49
|
+
* 心跳长过期说明写方早已停摆 → 允许**显式**回收判为残留锁;acquire 侧仍绝不自动摘锁。 */
|
|
50
|
+
export const DEFAULT_LONG_EXPIRED_AFTER_MS = 30 * 60_000;
|
|
51
|
+
/**
|
|
52
|
+
* 便捷包装:核心引擎「无父 token → acquire;有有效父 token → reuse(不 reacquire)」。
|
|
53
|
+
* 返回 { token, context, release }:调用方在 finally 中调用 release(仅当本次真正 acquire 才 release)。
|
|
54
|
+
* 若未提供 port(测试/无锁环境)→ 恒成功、不锁定(token=null、release = no-op)。
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* const { context, release } = await withMutationLock(ctx.mutationLock, { op: 'import', target })
|
|
58
|
+
* if (!context) throw new Error('环境锁被占用') // token===null 且真正需要锁 → 被挡
|
|
59
|
+
* try { ...mutation...; await rollback(..., { lockContext: context }) } finally { await release() }
|
|
60
|
+
*/
|
|
61
|
+
/**
|
|
62
|
+
* 便捷包装:核心/宿主「无父 token → acquire;有有效父 token → reuse(不 reacquire)」。
|
|
63
|
+
* 返回 { context, release }:
|
|
64
|
+
* - context 非 null = 有锁(或已传入有效父 token 复用);release 仅当**本次真正 acquire**才实际释放;
|
|
65
|
+
* - context null = 锁不可得(被挡)或未配置锁环境。
|
|
66
|
+
* 调用方必须区分:port 未配置(无锁环境)→ context null 且可放行;port 已配置但 context null → 被挡必须拒绝。
|
|
67
|
+
*/
|
|
68
|
+
export async function withMutationLock(port, opts) {
|
|
69
|
+
// Phase 3 SAFE MODE:注入谓词被挡 → 拒绝(generic blocked?,不识 policy/why)
|
|
70
|
+
if (opts.isBlocked?.() === true) {
|
|
71
|
+
return { context: null, release: async () => { }, reason: 'blocked' };
|
|
72
|
+
}
|
|
73
|
+
if (port === undefined) {
|
|
74
|
+
// 无锁环境(mock/测试):不锁定,调用方自行保证(返回 null + no-op release)
|
|
75
|
+
return { context: null, release: async () => { } };
|
|
76
|
+
}
|
|
77
|
+
// —— 若已持有有效父 token:复用,不 reacquire,且 release = no-op(父负责最终释放)——
|
|
78
|
+
if (opts.parentContext !== undefined && port.validate(opts.parentContext.token)) {
|
|
79
|
+
return { context: opts.parentContext, release: async () => { } };
|
|
80
|
+
}
|
|
81
|
+
const res = await port.acquire({ op: opts.op, target: opts.target });
|
|
82
|
+
if (res.state !== 'ACQUIRED' || res.token === null) {
|
|
83
|
+
// 锁不可得:不放行。区分「被活跃任务占用」(LOCKED)与「锁不可用」(STALE/UNKNOWN/IO/PERM)
|
|
84
|
+
// —— 只在 LOCKED 时向用户说「另一个任务在运行」,其余诚实说「暂无法执行」(不谎称在运行)。
|
|
85
|
+
// stale 单独成类(issue #27):残留锁「重试/重启都不会好」,必须显式回收——
|
|
86
|
+
// 若与别的不可用原因共用「请稍后重试」文案,用户会一直等到放弃(实测反馈即如此)。
|
|
87
|
+
const reason = res.state === 'LOCKED'
|
|
88
|
+
? 'locked'
|
|
89
|
+
: res.state === 'STALE_LOCK_DETECTED'
|
|
90
|
+
? 'stale'
|
|
91
|
+
: 'unavailable';
|
|
92
|
+
return { context: null, release: async () => { }, reason, detail: res.detail };
|
|
93
|
+
}
|
|
94
|
+
const context = { token: res.token };
|
|
95
|
+
let released = false;
|
|
96
|
+
return {
|
|
97
|
+
context,
|
|
98
|
+
release: async () => {
|
|
99
|
+
if (released)
|
|
100
|
+
return;
|
|
101
|
+
released = true;
|
|
102
|
+
await port.release(res.token).catch(() => { });
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* 直接执行式的 mutation 守卫:acquire(或复用父 token)→ 执行 → 释放。
|
|
108
|
+
* - port 未配置(无锁环境/测试)→ 直接执行 fn(null),不锁定。
|
|
109
|
+
* - port 已配置但 acquire 失败 → **抛 EnvironmentLockUnavailableError**(destructive 不得执行)。
|
|
110
|
+
* - 复用父 token 时(parentContext 有效)→ 不 reacquire、不释放父 token,fn 收到父 context。
|
|
111
|
+
*/
|
|
112
|
+
export async function runWithMutationLock(port, opts, fn) {
|
|
113
|
+
if (port === undefined)
|
|
114
|
+
return fn(null);
|
|
115
|
+
const { context, release, reason, detail } = await withMutationLock(port, opts);
|
|
116
|
+
if (context === null) {
|
|
117
|
+
throw new EnvironmentLockUnavailableError(opts.op, reason ?? 'locked', detail);
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
return await fn(context);
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
await release();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** 判断某 state 是否意味着「未获得锁(被挡)」;ACQUIRED 才放行 destructive */
|
|
127
|
+
export function isAcquired(state) {
|
|
128
|
+
return state === 'ACQUIRED';
|
|
129
|
+
}
|
|
130
|
+
/** 默认 io 实现 */
|
|
131
|
+
function defaultIo() {
|
|
132
|
+
return {
|
|
133
|
+
async mkdir(d, o) { await fs.mkdir(d, o); },
|
|
134
|
+
async open(p, flag, mode) { return fs.open(p, flag, mode); },
|
|
135
|
+
async rename(a, b) { return fs.rename(a, b); },
|
|
136
|
+
async unlink(p) { return fs.unlink(p); },
|
|
137
|
+
async stat(p) { try {
|
|
138
|
+
return await fs.stat(p);
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
if (isENOENT(e))
|
|
142
|
+
return null;
|
|
143
|
+
throw e;
|
|
144
|
+
} },
|
|
145
|
+
async readFileText(p) { return (await fs.readFile(p, 'utf8')).toString(); },
|
|
146
|
+
async lstat(p) { try {
|
|
147
|
+
return await fs.lstat(p);
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
if (isENOENT(e))
|
|
151
|
+
return null;
|
|
152
|
+
throw e;
|
|
153
|
+
} },
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/** 默认进程探测(跨平台 best-effort;OS identity 能力由平台决定) */
|
|
157
|
+
function defaultProbe() {
|
|
158
|
+
const selfOsIdentity = (() => {
|
|
159
|
+
try {
|
|
160
|
+
if (process.platform === 'linux') {
|
|
161
|
+
// /proc/<pid>/stat 第 22 字段 = starttime(tick 数)
|
|
162
|
+
const l = fssync.readFileSync(`/proc/${process.pid}/stat`, 'utf8').toString();
|
|
163
|
+
const afterComm = l.slice(l.lastIndexOf(')') + 1).trim().split(/\s+/);
|
|
164
|
+
// 格式: state ppid ... starttime:comm 后第一字段是 state,starttime 是第 22 个(index 21 起)
|
|
165
|
+
return `linux:${afterComm[21] ?? 'unknown'}`;
|
|
166
|
+
}
|
|
167
|
+
if (process.platform === 'darwin')
|
|
168
|
+
return `darwin:${process.pid}:${Date.now()}`; // 不可靠 → 保守返回占位
|
|
169
|
+
if (process.platform === 'win32') {
|
|
170
|
+
// Windows 无简单 /proc;best-effort 用 process 自身属性(不真验 PID reuse,交给 probe 标记能力)
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
})();
|
|
179
|
+
const canGetOsIdentity = () => {
|
|
180
|
+
// Linux /proc 可靠;Windows/macOS 由 probe 运行时二次探测决定,这里保守:仅声明 Linux 能力
|
|
181
|
+
return process.platform === 'linux';
|
|
182
|
+
};
|
|
183
|
+
const probe = async (pid) => {
|
|
184
|
+
let alive = false;
|
|
185
|
+
let aliveConfirmed = false;
|
|
186
|
+
try {
|
|
187
|
+
process.kill(pid, 0);
|
|
188
|
+
alive = true; // 存在(或 EPERM 权限不足同样表示存在)
|
|
189
|
+
aliveConfirmed = true;
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
const code = e.code;
|
|
193
|
+
if (code === 'ESRCH') {
|
|
194
|
+
alive = false; // 确证不存在
|
|
195
|
+
aliveConfirmed = true;
|
|
196
|
+
}
|
|
197
|
+
else if (code === 'EPERM') {
|
|
198
|
+
alive = true; // 存在但无信号权限 → 视为 alive
|
|
199
|
+
aliveConfirmed = true;
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
// 平台不支持 / 其它错误 → 探测失败(不确定),抛错由上层归为 UNKNOWN_STATE;
|
|
203
|
+
// **绝不要**伪装成「确证死亡」——否则会把失败的探测当成 stale 删除许可。
|
|
204
|
+
throw new Error(`进程探测失败 pid=${pid}: ${e?.message ?? String(e)}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (!aliveConfirmed) {
|
|
208
|
+
// 防御:理论上到不了这里
|
|
209
|
+
throw new Error(`进程探测未确定 pid=${pid}`);
|
|
210
|
+
}
|
|
211
|
+
let osIdentity = null;
|
|
212
|
+
if (alive) {
|
|
213
|
+
if (process.platform === 'linux') {
|
|
214
|
+
try {
|
|
215
|
+
const l = fssync.readFileSync(`/proc/${pid}/stat`, 'utf8').toString();
|
|
216
|
+
const afterComm = l.slice(l.lastIndexOf(')') + 1).trim().split(/\s+/);
|
|
217
|
+
osIdentity = `linux:${afterComm[21] ?? 'unknown'}`;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
osIdentity = null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
else if (process.platform === 'win32') {
|
|
224
|
+
// best-effort:Node 无法直接读其它进程 creation time;留给注入实现。这里返回 null = 无法验证。
|
|
225
|
+
osIdentity = null;
|
|
226
|
+
}
|
|
227
|
+
else if (process.platform === 'darwin') {
|
|
228
|
+
osIdentity = null; // 依赖 ps 的实现应由宿主注入;默认保守
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
osIdentity = null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return { alive, osProcessStartIdentity: osIdentity };
|
|
235
|
+
};
|
|
236
|
+
return { probe, canGetOsIdentity };
|
|
237
|
+
}
|
|
238
|
+
function isENOENT(e) {
|
|
239
|
+
return typeof e === 'object' && e !== null && e.code === 'ENOENT';
|
|
240
|
+
}
|
|
241
|
+
/** 解析 lock 文件 JSON,损坏/非法 → null */
|
|
242
|
+
function parseJsonSafe(text) {
|
|
243
|
+
try {
|
|
244
|
+
return JSON.parse(text);
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function randomHex(nBytes) {
|
|
251
|
+
return crypto.randomBytes(nBytes).toString('hex');
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* EnvironmentLockManager:跨进程环境锁管理器。
|
|
255
|
+
*
|
|
256
|
+
* 用法:
|
|
257
|
+
* const mgr = new EnvironmentLockManager({ locksDir })
|
|
258
|
+
* const { state, token } = await mgr.acquire({ op: 'import' })
|
|
259
|
+
* if (state !== 'ACQUIRED') throw ... // destructive 必须成功 acquire
|
|
260
|
+
* try { ...mutation... } finally { await mgr.release(token) }
|
|
261
|
+
* // nested rollback:mutation 内 rollback(..., { lockContext: { token } }) → reuse,不 reacquire
|
|
262
|
+
*/
|
|
263
|
+
export class EnvironmentLockManager {
|
|
264
|
+
locksDir;
|
|
265
|
+
io;
|
|
266
|
+
probe;
|
|
267
|
+
now;
|
|
268
|
+
heartbeatIntervalMs;
|
|
269
|
+
staleAfterMs;
|
|
270
|
+
longExpiredAfterMs;
|
|
271
|
+
acquireTimeoutMs;
|
|
272
|
+
lockVersion;
|
|
273
|
+
onHeartbeatWriteFailure;
|
|
274
|
+
/** 诊断用(acquire 时写 ownership.op/target) */
|
|
275
|
+
defaultOp;
|
|
276
|
+
defaultTarget;
|
|
277
|
+
/** 本 manager 唯一 id(forever token 校验用) */
|
|
278
|
+
managerId;
|
|
279
|
+
/** 本 manager 持有的当前活跃 token(单锁单持有者) */
|
|
280
|
+
activeToken = null;
|
|
281
|
+
heartbeatTimer = null;
|
|
282
|
+
heartbeatSeq = 0;
|
|
283
|
+
activeInstanceId;
|
|
284
|
+
heartbeatDegraded = false;
|
|
285
|
+
/** 串行化的 heartbeat 写链(**最后一次写**的 promise);release 前用于 drain,见 drainHeartbeat */
|
|
286
|
+
pendingHeartbeat = Promise.resolve();
|
|
287
|
+
/** 瞬时错误(EBUSY 等)有界重试计数 */
|
|
288
|
+
transientRetries = 0;
|
|
289
|
+
maxTransientRetries = 5;
|
|
290
|
+
/** 有界瞬时重试:EBUSY(Windows sharing violation / 杀软)后小退避重试;超限则返回 false(交由 classify) */
|
|
291
|
+
async tryTransientRetry(kind) {
|
|
292
|
+
if (this.transientRetries >= this.maxTransientRetries) {
|
|
293
|
+
this.transientRetries = 0;
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
this.transientRetries += 1;
|
|
297
|
+
await sleep(Math.max(this.heartbeatIntervalMs / 4, 20));
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
/** 重置瞬时重试计数(每次 acquire 成功/失败收敛时调用) */
|
|
301
|
+
resetTransientRetries() { this.transientRetries = 0; }
|
|
302
|
+
constructor(opts = {}) {
|
|
303
|
+
this.locksDir = opts.locksDir ?? path.join(opts.dataDir ?? path.join(os.homedir(), '.dsh', 'dsh-config-manager'), LOCKS_DIR);
|
|
304
|
+
this.io = opts.io ?? defaultIo();
|
|
305
|
+
this.probe = opts.probe ?? defaultProbe();
|
|
306
|
+
this.now = opts.now ?? (() => Date.now());
|
|
307
|
+
this.heartbeatIntervalMs = opts.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
308
|
+
this.staleAfterMs = opts.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
309
|
+
// issue #36:阈值取「30 倍 stale 窗口」与「30 分钟」的较大者——活着的 owner 连续 30 分钟
|
|
310
|
+
// 一次心跳都写不进去只可能是 ACL/磁盘级别的异常;而残留锁的用户等 9 天都等不到自愈。
|
|
311
|
+
this.longExpiredAfterMs = opts.longExpiredAfterMs ?? Math.max(this.staleAfterMs * 30, DEFAULT_LONG_EXPIRED_AFTER_MS);
|
|
312
|
+
this.acquireTimeoutMs = opts.acquireTimeoutMs ?? DEFAULT_ACQUIRE_TIMEOUT_MS;
|
|
313
|
+
this.lockVersion = opts.lockVersion ?? '0.1.0';
|
|
314
|
+
this.onHeartbeatWriteFailure = opts.onHeartbeatWriteFailure ?? (() => { });
|
|
315
|
+
this.defaultOp = opts.op ?? 'mutation';
|
|
316
|
+
this.defaultTarget = opts.target ?? 'unknown';
|
|
317
|
+
this.managerId = randomHex(16);
|
|
318
|
+
this.activeInstanceId = randomHex(16);
|
|
319
|
+
}
|
|
320
|
+
get ownershipPath() { return path.join(this.locksDir, OWNERSHIP_FILE); }
|
|
321
|
+
get locksDirectory() { return this.locksDir; }
|
|
322
|
+
/** 本 manager 是否当前持有锁(诊断用) */
|
|
323
|
+
get isHolding() { return this.activeToken !== null; }
|
|
324
|
+
/* ------------------------------------------------------------ acquire */
|
|
325
|
+
/**
|
|
326
|
+
* 尝试获取 GLOBAL mutation lock。
|
|
327
|
+
* - 成功 → state=ACQUIRED,返回 token(token 只属于当前调用链)。
|
|
328
|
+
* - 被活跃 owner 持有 → LOCKED(可选等待 acquireTimeoutMs 后仍失败)。
|
|
329
|
+
* - 确定 stale → STALE_LOCK_DETECTED(**不自动删除**;destructive 不得执行)。
|
|
330
|
+
* - 其它 → UNKNOWN_STATE / LOCK_IO_ERROR / PERMISSION_ERROR。
|
|
331
|
+
*
|
|
332
|
+
* 所有权用 `open(ownershipPath, 'wx')` 独占创建;**绝不用 atomicWriteFile 获取所有权**。
|
|
333
|
+
*/
|
|
334
|
+
async acquire(opts = {}) {
|
|
335
|
+
const op = opts.op ?? this.defaultOp;
|
|
336
|
+
const target = opts.target ?? this.defaultTarget;
|
|
337
|
+
const started = this.now();
|
|
338
|
+
// 预取自身 OS identity(F3 修复):在 open('wx') 之前完成异步探测,避免 open→writeFile 间隙内 await
|
|
339
|
+
// (否则该间隙被杀会留下 0 字节 environment.lock → 永久 LOCKED)。探测失败降级为 null(不废掉合法 acquire)。
|
|
340
|
+
const selfOsIdentity = this.probe.canGetOsIdentity()
|
|
341
|
+
? await this.probe.probe(process.pid).then((p) => p.osProcessStartIdentity).catch(() => null)
|
|
342
|
+
: null;
|
|
343
|
+
for (;;) {
|
|
344
|
+
// —— 先确保 locks 目录存在(mkdir 递归;EPERM 在下面归类为 PERMISSION_ERROR)——
|
|
345
|
+
try {
|
|
346
|
+
await this.io.mkdir(this.locksDir, { recursive: true });
|
|
347
|
+
}
|
|
348
|
+
catch (e) {
|
|
349
|
+
return classifyIoError(e, `创建锁目录 ${this.locksDir}`, this.io);
|
|
350
|
+
}
|
|
351
|
+
// —— 独占创建所有权文件(唯一所有权获取原语)——
|
|
352
|
+
let handle = null;
|
|
353
|
+
try {
|
|
354
|
+
handle = await this.io.open(this.ownershipPath, 'wx', 0o600);
|
|
355
|
+
}
|
|
356
|
+
catch (e) {
|
|
357
|
+
const code = e.code;
|
|
358
|
+
// EBUSY:Windows sharing violation / 杀软 / 索引器瞬时占用 → 有界重试(Windows P2-3)
|
|
359
|
+
if (code === 'EBUSY') {
|
|
360
|
+
const transient = await this.tryTransientRetry('open');
|
|
361
|
+
if (transient)
|
|
362
|
+
continue;
|
|
363
|
+
return classifyIoError(e, `open('wx') ${this.ownershipPath}`, this.io);
|
|
364
|
+
}
|
|
365
|
+
if (code === 'EEXIST') {
|
|
366
|
+
// 已存在 → inspect owner
|
|
367
|
+
const inspect = await this.inspectLockState();
|
|
368
|
+
if (inspect.state === 'LOCKED') {
|
|
369
|
+
// 等 acquireTimeoutMs 后重试;超时则返回 LOCKED
|
|
370
|
+
if (this.acquireTimeoutMs > 0 && this.now() - started < this.acquireTimeoutMs) {
|
|
371
|
+
await sleep(this.heartbeatIntervalMs / 2);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
return { state: 'LOCKED', token: null, detail: inspect.detail };
|
|
375
|
+
}
|
|
376
|
+
// STALE/UNKNOWN/IO 直接返回(不自动删除;destructive 不执行)
|
|
377
|
+
return { state: inspect.state, token: null, detail: inspect.detail };
|
|
378
|
+
}
|
|
379
|
+
// EPERM/EACCES 等(Windows open('wx') 对存在文件常抛 EPERM 而非 EEXIST):
|
|
380
|
+
// 按 §8.1 分类 —— 先检查 environment.lock 是否确实存在且可读取;
|
|
381
|
+
// 存在 → 按 existing lock inspect(可能正是活跃锁被 Windows 以 EPERM 拒绝);
|
|
382
|
+
// 不存在/无法确认 → LOCK_IO_ERROR / PERMISSION_ERROR(绝不误报「Locked/另一任务在运行」)。
|
|
383
|
+
if (code === 'EPERM' || code === 'EACCES') {
|
|
384
|
+
const exists = await this.statLockExists();
|
|
385
|
+
if (exists === true) {
|
|
386
|
+
const inspect = await this.inspectLockState();
|
|
387
|
+
if (inspect.state === 'LOCKED' && this.acquireTimeoutMs > 0 && this.now() - started < this.acquireTimeoutMs) {
|
|
388
|
+
await sleep(this.heartbeatIntervalMs / 2);
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
return { state: inspect.state, token: null, detail: inspect.detail };
|
|
392
|
+
}
|
|
393
|
+
// 无既有锁 → 权限/ACL/文件系统错误(可能目录不可写 / ACL 拒绝),非锁占用
|
|
394
|
+
return exists === false
|
|
395
|
+
? { state: 'PERMISSION_ERROR', token: null, detail: `open('wx') ${this.ownershipPath}: 权限/ACL 错误 (${code}),且未发现既有锁文件` }
|
|
396
|
+
: classifyIoError(e, `open('wx') ${this.ownershipPath}`, this.io);
|
|
397
|
+
}
|
|
398
|
+
return classifyIoError(e, `open('wx') ${this.ownershipPath}`, this.io);
|
|
399
|
+
}
|
|
400
|
+
// —— 独占创建成功:写入 immutable owner(一次性,之后不再替换)——
|
|
401
|
+
try {
|
|
402
|
+
const ownerRecord = {
|
|
403
|
+
schemaVersion: LOCK_SCHEMA_VERSION,
|
|
404
|
+
owner: {
|
|
405
|
+
instanceId: this.activeInstanceId,
|
|
406
|
+
instanceStartedAt: Date.now(),
|
|
407
|
+
pid: process.pid,
|
|
408
|
+
hostname: os.hostname(),
|
|
409
|
+
osProcessStartIdentity: selfOsIdentity,
|
|
410
|
+
},
|
|
411
|
+
op,
|
|
412
|
+
target,
|
|
413
|
+
acquiredAt: this.now(),
|
|
414
|
+
lockVersion: this.lockVersion,
|
|
415
|
+
journalId: null,
|
|
416
|
+
};
|
|
417
|
+
await handle.writeFile(encode(ownerRecord));
|
|
418
|
+
await handle.sync();
|
|
419
|
+
await handle.close();
|
|
420
|
+
handle = null;
|
|
421
|
+
}
|
|
422
|
+
catch (e) {
|
|
423
|
+
// 写入 owner 失败:必须回滚 —— 关闭句柄 + 尽力删除刚创建的 lock(此刻所有权尚未确立,删除自己是安全的)
|
|
424
|
+
if (handle)
|
|
425
|
+
try {
|
|
426
|
+
await handle.close();
|
|
427
|
+
}
|
|
428
|
+
catch { /* ignore */ }
|
|
429
|
+
this.activeToken = null;
|
|
430
|
+
try {
|
|
431
|
+
await this.io.unlink(this.ownershipPath);
|
|
432
|
+
}
|
|
433
|
+
catch { /* 清理失败留痕由上层 */ }
|
|
434
|
+
return classifyIoError(e, `写入 owner ${this.ownershipPath}`, this.io);
|
|
435
|
+
}
|
|
436
|
+
// —— 确立 token + 启动 heartbeat ——
|
|
437
|
+
const token = {
|
|
438
|
+
tokenId: randomHex(16),
|
|
439
|
+
managerId: this.managerId,
|
|
440
|
+
instanceId: this.activeInstanceId,
|
|
441
|
+
acquiredAt: this.now(),
|
|
442
|
+
};
|
|
443
|
+
this.activeToken = token;
|
|
444
|
+
this.heartbeatSeq = 0;
|
|
445
|
+
this.heartbeatDegraded = false;
|
|
446
|
+
this.startHeartbeat();
|
|
447
|
+
return { state: 'ACQUIRED', token, detail: `op=${op}` };
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/* ------------------------------------------------------------ validate / release */
|
|
451
|
+
/**
|
|
452
|
+
* 校验 token 是否有效且属于当前持有(供 nested operation 判断能否 reuse)。
|
|
453
|
+
* 返回 true = 该 token 授权本调用链 reuse 当前持有。
|
|
454
|
+
*/
|
|
455
|
+
validate(token) {
|
|
456
|
+
const t = token;
|
|
457
|
+
if (t === null || t === undefined)
|
|
458
|
+
return false;
|
|
459
|
+
if (this.activeToken === null)
|
|
460
|
+
return false; // 未持有
|
|
461
|
+
if (t.managerId !== this.managerId)
|
|
462
|
+
return false; // foreign token
|
|
463
|
+
if (t.tokenId !== this.activeToken.tokenId)
|
|
464
|
+
return false; // 非当前 token / 已 release
|
|
465
|
+
// 显式 instanceId 三重匹配(P2-1 纵深防御:tokenId 唯一绑定 instanceId,此处显式断言防未来拆解)
|
|
466
|
+
if (t.instanceId !== this.activeInstanceId)
|
|
467
|
+
return false;
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* 释放锁:release 前**校验 ownership record 的 instanceId === token.instanceId**(以及 manager/tokenId)。
|
|
472
|
+
* 匹配 → 清理 heartbeat + unlink ownership;不匹配 → **不 unlink**,记录 ownership-lost violation。
|
|
473
|
+
*
|
|
474
|
+
* 错误语义(F2 修复):仅当磁盘 ownership 被确认成功删除后才清空 activeToken;unlink 失败/异常时
|
|
475
|
+
* **保留 activeToken**(本调用链仍持有该 inode),允许调用方重试 release,绝不留下"令牌已失效但锁在磁盘上"的卡死态。
|
|
476
|
+
*/
|
|
477
|
+
async release(token) {
|
|
478
|
+
if (!this.validate(token)) {
|
|
479
|
+
// 可能已 release 或 foreign:幂等返回(已 release 再次 release 无害)
|
|
480
|
+
if (this.activeToken === null)
|
|
481
|
+
return;
|
|
482
|
+
throw new EnvironmentLockOwnedByAnotherError('release: token 不匹配当前持有(foreign/已被接管),拒绝 unlink');
|
|
483
|
+
}
|
|
484
|
+
const instanceId = this.activeInstanceId;
|
|
485
|
+
this.stopHeartbeat();
|
|
486
|
+
// —— release 前校验磁盘 ownership record 仍属于自己(防异常恢复/人工修改)——
|
|
487
|
+
const st = await this.readOwnershipState();
|
|
488
|
+
if (st.kind === 'missing') {
|
|
489
|
+
// ownership 文件不存在:已被清除/尚未落盘 → 视为已释放;清 token + 尽力清 heartbeat
|
|
490
|
+
// 先清 token(writeHeartbeat 的「不再写」闸门)再 drain,最后清理 sidecar(同成功路径的顺序理由)
|
|
491
|
+
this.heartbeatDegraded = false;
|
|
492
|
+
this.activeToken = null;
|
|
493
|
+
await this.drainHeartbeat();
|
|
494
|
+
await this.cleanupHeartbeat(instanceId).catch(() => { });
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (st.kind === 'corrupt') {
|
|
498
|
+
// ownership 存在但损坏/不可读:无法确证属于自己 → ownership-lost,不 unlink(防误删他人/异常文件)
|
|
499
|
+
this.heartbeatDegraded = false;
|
|
500
|
+
this.activeToken = null;
|
|
501
|
+
throw new EnvironmentLockOwnedByAnotherError(`release: 磁盘 ownership 无法读取/损坏(可能被异常恢复或人工修改),拒绝 unlink(ownership-lost)`);
|
|
502
|
+
}
|
|
503
|
+
if (st.rec.owner.instanceId !== instanceId) {
|
|
504
|
+
throw new EnvironmentLockOwnedByAnotherError(`release: 磁盘 ownership.instanceId=${st.rec.owner.instanceId} !== 本 token ${instanceId}(ownership-lost)`);
|
|
505
|
+
}
|
|
506
|
+
// —— matching:unlink ——(acquire 成功后句柄已 close,此处只有 unlink)
|
|
507
|
+
try {
|
|
508
|
+
await this.io.unlink(this.ownershipPath);
|
|
509
|
+
}
|
|
510
|
+
catch (e) {
|
|
511
|
+
// unlink 失败:**保留 activeToken**(仍持有该磁盘 inode),调用方可重试 release;
|
|
512
|
+
// 绝不在此清空 token(否则锁卡死在磁盘而令牌失效)。
|
|
513
|
+
throw new EnvironmentLockIOError(`release: unlink ${this.ownershipPath} 失败: ${e instanceof Error ? e.message : String(e)}`, e);
|
|
514
|
+
}
|
|
515
|
+
// unlink 成功 → 释放完成:**先清 token 再 drain**,最后清自己的 heartbeat sidecar。
|
|
516
|
+
// 顺序关键:activeToken=null 必须早于 drain —— 否则 interval 可能在 drain 返回之后、cleanup 之前
|
|
517
|
+
// 再排入一次写并真正落盘,把刚删掉的 sidecar 复活(writeHeartbeat 以 activeToken===null 作为「不再写」的闸门)。
|
|
518
|
+
// 本仓库实测:顺序颠倒时 L3 回归用例可稳定复现 sidecar 复活(该用例正是捕获了这一点)。
|
|
519
|
+
this.heartbeatDegraded = false;
|
|
520
|
+
this.activeToken = null;
|
|
521
|
+
await this.drainHeartbeat();
|
|
522
|
+
await this.cleanupHeartbeat(instanceId).catch(() => { });
|
|
523
|
+
}
|
|
524
|
+
/* ------------------------------------------------------------ heartbeat */
|
|
525
|
+
startHeartbeat() {
|
|
526
|
+
if (this.heartbeatTimer !== null)
|
|
527
|
+
return;
|
|
528
|
+
this.heartbeatTimer = setInterval(() => { void this.trackHeartbeat(); }, Math.max(this.heartbeatIntervalMs, 50));
|
|
529
|
+
if (this.heartbeatTimer.unref)
|
|
530
|
+
this.heartbeatTimer.unref();
|
|
531
|
+
// 立即写一次,确立初始 heartbeat(stale 窗口从此刻起)——走 trackHeartbeat 纳入可 drain 的串行链:
|
|
532
|
+
// 否则这个 fire-and-forget 的写可能在 release 清理 sidecar 之后才落盘,把 sidecar 重新创建(或残留 .dshcm.*.tmp),
|
|
533
|
+
// 在 Windows 上即表现为目录清理竞态(after-hook rmSync ENOTEMPTY)。
|
|
534
|
+
this.trackHeartbeat();
|
|
535
|
+
}
|
|
536
|
+
stopHeartbeat() {
|
|
537
|
+
if (this.heartbeatTimer !== null) {
|
|
538
|
+
clearInterval(this.heartbeatTimer);
|
|
539
|
+
this.heartbeatTimer = null;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* 等待**当前在途**的 heartbeat 写完成(release 清理 sidecar 前必须调用)。
|
|
544
|
+
* 为什么必要:writeHeartbeat 走 atomicWriteFile(tmp 写入 → rename),是异步多步操作。
|
|
545
|
+
* 若 release 只 stopHeartbeat + unlink sidecar 而不等待,一个已启动的写会在 unlink 之后才 rename,
|
|
546
|
+
* 于是把刚删掉的 sidecar **重新创建**(或残留 .dshcm.*.tmp)——Windows 上即 after-hook rmSync ENOTEMPTY。
|
|
547
|
+
* 调用点保证:release 先同步 stopHeartbeat() 并置 activeToken=null,故此刻起不会有新的写开始;
|
|
548
|
+
* 因此 drain 之后 cleanupHeartbeat 删除的 sidecar 不会再被复活。
|
|
549
|
+
*/
|
|
550
|
+
async drainHeartbeat() {
|
|
551
|
+
try {
|
|
552
|
+
await this.pendingHeartbeat;
|
|
553
|
+
}
|
|
554
|
+
catch { /* writeHeartbeat 自身已吞错;此处仅防御 */ }
|
|
555
|
+
}
|
|
556
|
+
/** 串行化并追踪一次 heartbeat 写:chain 保证不会有两个写并发 rename 同一个 sidecar */
|
|
557
|
+
trackHeartbeat() {
|
|
558
|
+
this.pendingHeartbeat = this.pendingHeartbeat.then(() => this.writeHeartbeat());
|
|
559
|
+
return this.pendingHeartbeat;
|
|
560
|
+
}
|
|
561
|
+
/** 写 heartbeat sidecar(atomicWriteFile 更新 sidecar,不影响 ownership;失败 → degraded,不中断 mutation) */
|
|
562
|
+
async writeHeartbeat() {
|
|
563
|
+
if (this.activeToken === null)
|
|
564
|
+
return;
|
|
565
|
+
const rec = {
|
|
566
|
+
ownerInstanceId: this.activeInstanceId,
|
|
567
|
+
heartbeatAt: this.now(),
|
|
568
|
+
seq: ++this.heartbeatSeq,
|
|
569
|
+
};
|
|
570
|
+
const sbPath = path.join(this.locksDir, `${HEARTBEAT_PREFIX}${this.activeInstanceId}`);
|
|
571
|
+
try {
|
|
572
|
+
await atomicWriteFile(sbPath, encode(rec), { mode: 0o600 });
|
|
573
|
+
this.heartbeatDegraded = false;
|
|
574
|
+
}
|
|
575
|
+
catch (e) {
|
|
576
|
+
this.heartbeatDegraded = true;
|
|
577
|
+
this.onHeartbeatWriteFailure(e);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
/** 删除指定 instanceId 的 heartbeat sidecar(不受 activeToken 状态影响——F1 修复;调用方传自己的 instanceId)。 */
|
|
581
|
+
async cleanupHeartbeat(instanceId) {
|
|
582
|
+
const sbPath = path.join(this.locksDir, `${HEARTBEAT_PREFIX}${instanceId}`);
|
|
583
|
+
try {
|
|
584
|
+
await this.io.unlink(sbPath);
|
|
585
|
+
}
|
|
586
|
+
catch (e) {
|
|
587
|
+
if (!isENOENT(e))
|
|
588
|
+
this.onHeartbeatWriteFailure(e);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
/* ------------------------------------------------------------ inspect / stale */
|
|
592
|
+
/** 检查 environment.lock 是否确实存在(§8.1 分类用;stat 失败/无法确认 → null) */
|
|
593
|
+
async statLockExists() {
|
|
594
|
+
try {
|
|
595
|
+
const st = await this.io.stat(this.ownershipPath);
|
|
596
|
+
return st !== null;
|
|
597
|
+
}
|
|
598
|
+
catch (e) {
|
|
599
|
+
if (isENOENT(e))
|
|
600
|
+
return false;
|
|
601
|
+
return null; // IO error 无法确认
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
/** ownership 三态读取:missing(缺失)/ corrupt(存在但空或非法)/ ok(有效 owner)。
|
|
605
|
+
* 用于区分「无锁」与「崩溃残留的 0 字节/损坏锁」——后者可被显式 recovery 安全回收(无有效 owner 无从误删)。 */
|
|
606
|
+
async readOwnershipState() {
|
|
607
|
+
let text;
|
|
608
|
+
try {
|
|
609
|
+
text = await this.io.readFileText(this.ownershipPath);
|
|
610
|
+
}
|
|
611
|
+
catch (e) {
|
|
612
|
+
if (isENOENT(e))
|
|
613
|
+
return { kind: 'missing' };
|
|
614
|
+
// ACL 不可读(EACCES 等)→ 视为 corrupt-unknown(无法确证 owner),安全侧
|
|
615
|
+
return { kind: 'corrupt' };
|
|
616
|
+
}
|
|
617
|
+
const parsed = parseJsonSafe(text);
|
|
618
|
+
if (parsed === null || typeof parsed !== 'object' || parsed.schemaVersion !== LOCK_SCHEMA_VERSION
|
|
619
|
+
|| typeof parsed.owner?.instanceId !== 'string') {
|
|
620
|
+
return { kind: 'corrupt' };
|
|
621
|
+
}
|
|
622
|
+
return { kind: 'ok', rec: parsed };
|
|
623
|
+
}
|
|
624
|
+
/** 读取 owner instanceId 的 heartbeat sidecar(无 → null) */
|
|
625
|
+
async readHeartbeat(instanceId) {
|
|
626
|
+
const sbPath = path.join(this.locksDir, `${HEARTBEAT_PREFIX}${instanceId}`);
|
|
627
|
+
let text;
|
|
628
|
+
try {
|
|
629
|
+
text = await this.io.readFileText(sbPath);
|
|
630
|
+
}
|
|
631
|
+
catch (e) {
|
|
632
|
+
if (isENOENT(e))
|
|
633
|
+
return null;
|
|
634
|
+
throw e;
|
|
635
|
+
}
|
|
636
|
+
const parsed = parseJsonSafe(text);
|
|
637
|
+
if (parsed === null || typeof parsed !== 'object' || parsed.ownerInstanceId !== instanceId)
|
|
638
|
+
return null;
|
|
639
|
+
return parsed;
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* 判定锁状态(只分类)。按 Design §6.3 正式状态表:
|
|
643
|
+
* heartbeat fresh → LOCKED
|
|
644
|
+
* heartbeat expired + PID dead → STALE_LOCK_DETECTED
|
|
645
|
+
* heartbeat expired + PID alive + identity 不同 → STALE_LOCK_DETECTED (PID reuse)
|
|
646
|
+
* heartbeat expired + PID alive + identity 相同 → LOCKED (owner alive / heartbeat degraded)
|
|
647
|
+
* probe 无法可靠确定 → UNKNOWN_STATE
|
|
648
|
+
*/
|
|
649
|
+
async inspectLockState() {
|
|
650
|
+
const st = await this.readOwnershipState();
|
|
651
|
+
if (st.kind === 'missing') {
|
|
652
|
+
// 无所有权文件:可能在创建中,或已被清除 → 保守按"非空闲"处理
|
|
653
|
+
return { state: 'LOCKED', detail: 'ownership file 不存在(可能正被创建中)' };
|
|
654
|
+
}
|
|
655
|
+
if (st.kind === 'corrupt') {
|
|
656
|
+
// 存在但空/损坏/ACL 不可读:崩溃窗口残留(open('wx') 后未写完 owner 即死),无有效 owner
|
|
657
|
+
// 无从误删;acquire 侧不执行(UNKNOWN_STATE),但显式 recovery 可安全回收(见 recoverStaleLock)
|
|
658
|
+
return { state: 'UNKNOWN_STATE', detail: 'ownership 存在但无有效 owner(可能崩溃残留空/损坏文件),不删除、不执行;可用 --recover-stale-lock 回收' };
|
|
659
|
+
}
|
|
660
|
+
const rec = st.rec;
|
|
661
|
+
// heartbeat 读取失败(EACCES)→ 无法确认 fresh → 保守不判 stale(避免误删)
|
|
662
|
+
let heartbeat = null;
|
|
663
|
+
try {
|
|
664
|
+
heartbeat = await this.readHeartbeat(rec.owner.instanceId);
|
|
665
|
+
}
|
|
666
|
+
catch (e) {
|
|
667
|
+
const code = e.code;
|
|
668
|
+
return code === 'EACCES' || code === 'EPERM'
|
|
669
|
+
? { state: 'UNKNOWN_STATE', detail: '无法读取 heartbeat(权限/ACL),保守拒绝' }
|
|
670
|
+
: { state: 'UNKNOWN_STATE', detail: 'heartbeat 读取失败,保守拒绝' };
|
|
671
|
+
}
|
|
672
|
+
const now = this.now();
|
|
673
|
+
const heartbeatFresh = heartbeat !== null && (now - heartbeat.heartbeatAt) <= this.staleAfterMs;
|
|
674
|
+
if (heartbeatFresh) {
|
|
675
|
+
return { state: 'LOCKED', detail: `owner op=${rec.op} pid=${rec.owner.pid} (heartbeat fresh)` };
|
|
676
|
+
}
|
|
677
|
+
// heartbeat 已过期:先探测 PID liveness(kill(pid,0) 跨平台可靠;ESRCH = 确证不存在)
|
|
678
|
+
let ident;
|
|
679
|
+
try {
|
|
680
|
+
ident = await this.probe.probe(rec.owner.pid);
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
return { state: 'UNKNOWN_STATE', detail: `进程探测失败 pid=${rec.owner.pid}` };
|
|
684
|
+
}
|
|
685
|
+
// 确证死亡(ESRCH)→ STALE(不依赖 OS identity 能力)
|
|
686
|
+
if (!ident.alive) {
|
|
687
|
+
return { state: 'STALE_LOCK_DETECTED', detail: `owner pid=${rec.owner.pid} 确证不存在 (heartbeat expired)` };
|
|
688
|
+
}
|
|
689
|
+
// PID 存活:需 OS identity 区分「reuse」与「同一进程 alive(heartbeat degraded)」
|
|
690
|
+
// —— capability/值缺失 → 无法可靠确定 → UNKNOWN_STATE(保守拒删)
|
|
691
|
+
if (!this.probe.canGetOsIdentity() || rec.owner.osProcessStartIdentity === null || ident.osProcessStartIdentity === null) {
|
|
692
|
+
// issue #36:Windows 默认无 OS identity 能力 + PID 会被复用 → 该分支此前永远停在
|
|
693
|
+
// UNKNOWN_STATE(连官方 recover-stale-lock 都拒绝),用户只能手工删锁文件。
|
|
694
|
+
// 心跳**长过期**(远超窗口,见 longExpiredAfterMs)说明写方早已停摆,判定为残留锁:
|
|
695
|
+
// 只影响显式回收与状态分类,acquire 侧依旧不自动摘锁。
|
|
696
|
+
const longExpired = this.longExpiredReason(heartbeat, now);
|
|
697
|
+
if (longExpired !== null) {
|
|
698
|
+
return {
|
|
699
|
+
state: 'STALE_LOCK_DETECTED',
|
|
700
|
+
detail: `owner pid=${rec.owner.pid} 存活但无法验证进程身份(可能是 PID 复用),且 heartbeat ${longExpired} → 判定为残留锁,可显式回收`,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
return {
|
|
704
|
+
state: 'UNKNOWN_STATE',
|
|
705
|
+
detail: `heartbeat 过期且 pid=${rec.owner.pid} 存活,但无法可靠取得 OS process identity,保守拒绝删除`,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
if (rec.owner.osProcessStartIdentity !== ident.osProcessStartIdentity) {
|
|
709
|
+
return { state: 'STALE_LOCK_DETECTED', detail: `pid reuse:pid=${rec.owner.pid} identity 与 recorded 不同` };
|
|
710
|
+
}
|
|
711
|
+
// alive 且 identity 相同 → owner alive(heartbeat degraded 保护)
|
|
712
|
+
return { state: 'LOCKED', detail: 'owner 进程存活(heartbeat 可能 degraded),非 stale' };
|
|
713
|
+
}
|
|
714
|
+
/* ------------------------------------------------------------ recover (explicit) */
|
|
715
|
+
/**
|
|
716
|
+
* 显式 stale recovery(独立动作;对应 CLI `--recover-stale-lock`)。
|
|
717
|
+
* 只执行:inspect → prove definitely stale → 原子 rename 捕获 → 二次验证 → unlink。
|
|
718
|
+
* **不自动触发**;仅当检测为 STALE_LOCK_DETECTED 才允许 capture。
|
|
719
|
+
* 二次验证失败 → quarantine(保留 recovering 文件,不 rename 回环境锁,不覆盖 successor)。
|
|
720
|
+
*/
|
|
721
|
+
async recoverStaleLock() {
|
|
722
|
+
// 1. inspect:必须 definitely stale 或 corrupt(崩溃残留无有效 owner)
|
|
723
|
+
const insp = await this.inspectLockState();
|
|
724
|
+
const isCorruptReclaim = insp.state === 'UNKNOWN_STATE' && insp.detail?.includes('无有效 owner');
|
|
725
|
+
if (insp.state !== 'STALE_LOCK_DETECTED' && !isCorruptReclaim) {
|
|
726
|
+
return { ok: false, removed: false, state: insp.state, detail: insp.detail ?? '非 stale,拒绝 recovery' };
|
|
727
|
+
}
|
|
728
|
+
const st = await this.readOwnershipState();
|
|
729
|
+
const rec = st.kind === 'ok' ? st.rec : null;
|
|
730
|
+
if (st.kind === 'missing') {
|
|
731
|
+
return { ok: false, removed: false, state: 'LOCKED', detail: 'ownership 已消失(被他人接管/清除),停止 recovery' };
|
|
732
|
+
}
|
|
733
|
+
// 2. 原子 rename 捕获当前 inode
|
|
734
|
+
const recoveringPath = path.join(this.locksDir, `${RECOVERING_PREFIX}${randomHex(8)}`);
|
|
735
|
+
try {
|
|
736
|
+
await this.io.rename(this.ownershipPath, recoveringPath);
|
|
737
|
+
}
|
|
738
|
+
catch (e) {
|
|
739
|
+
return { ok: false, removed: false, state: classifyIoError(e, `rename ${this.ownershipPath} → ${recoveringPath}`, this.io).state, detail: '捕获 rename 失败(可能被他人接管),停止' };
|
|
740
|
+
}
|
|
741
|
+
// 3. 二次验证:captured 内容仍是被判 stale 的那个 owner
|
|
742
|
+
let capturedText;
|
|
743
|
+
try {
|
|
744
|
+
capturedText = await this.io.readFileText(recoveringPath);
|
|
745
|
+
}
|
|
746
|
+
catch (e) {
|
|
747
|
+
// 读不到 captured(异常)→ quarantine,不 rename 回
|
|
748
|
+
return { ok: false, removed: false, state: 'LOCK_IO_ERROR', detail: '捕获文件读取失败,保留 recovering 供诊断' };
|
|
749
|
+
}
|
|
750
|
+
const captured = parseJsonSafe(capturedText);
|
|
751
|
+
if (isCorruptReclaim) {
|
|
752
|
+
// corrupt 回收:captured 无有效 owner(无 instanceId 可校验)→ 只确认它仍是非缺省非法内容 → unlink
|
|
753
|
+
// (captured 若已变成有效 owner,即 successor 在 rename 后才出现于 ownershipPath,与此 recovering 无涉)
|
|
754
|
+
if (captured !== null && typeof captured === 'object' && typeof captured.owner?.instanceId === 'string') {
|
|
755
|
+
// captured 突然变得有效(异常:rename 后被人写入)→ 保守 quarantine,不删
|
|
756
|
+
return {
|
|
757
|
+
ok: false, removed: false, state: 'UNKNOWN_STATE',
|
|
758
|
+
detail: `corrupt 回收二次验证异常:captured 具有效 owner,保留 ${path.basename(recoveringPath)} quarantine;不覆盖当前 environment.lock`,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
else {
|
|
763
|
+
// 有效 owner 的 stale 回收:校验 captured.instanceId 与 rec.instanceId 一致 + 二次 prove dead
|
|
764
|
+
const reProbeDead = await this.reProveStale(rec);
|
|
765
|
+
if (captured === null || typeof captured !== 'object'
|
|
766
|
+
|| captured.owner?.instanceId !== rec.owner.instanceId || !reProbeDead) {
|
|
767
|
+
// 二次验证失败 → quarantine:不 rename 回 environment.lock(防覆盖 successor),保留 recovering 文件
|
|
768
|
+
return {
|
|
769
|
+
ok: false, removed: false, state: 'UNKNOWN_STATE',
|
|
770
|
+
detail: `二次验证失败:保留 ${path.basename(recoveringPath)} quarantine;不覆盖当前 environment.lock(若有 successor)`,
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
// 4. 验证通过 → unlink captured
|
|
775
|
+
try {
|
|
776
|
+
await this.io.unlink(recoveringPath);
|
|
777
|
+
}
|
|
778
|
+
catch (e) {
|
|
779
|
+
return { ok: false, removed: false, state: classifyIoError(e, `unlink ${recoveringPath}`, this.io).state, detail: '删除 captured 失败' };
|
|
780
|
+
}
|
|
781
|
+
// 5. 清理该 owner 的 heartbeat sidecar(按 stale instanceId 匹配;corrupt 时 rec=null 跳过)
|
|
782
|
+
if (rec !== null) {
|
|
783
|
+
try {
|
|
784
|
+
const sbPath = path.join(this.locksDir, `${HEARTBEAT_PREFIX}${rec.owner.instanceId}`);
|
|
785
|
+
await this.io.unlink(sbPath).catch(() => { });
|
|
786
|
+
}
|
|
787
|
+
catch { /* 尽力 */ }
|
|
788
|
+
}
|
|
789
|
+
return {
|
|
790
|
+
ok: true, removed: true, state: 'STALE_LOCK_DETECTED',
|
|
791
|
+
detail: rec !== null ? `已移除 stale ownership (op=${rec.op}, pid=${rec.owner.pid})` : '已移除 corrupt/崩溃残留所有权文件',
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
/** recovery 二次验证:重新探测 recorded pid 是否确证死亡(保守——任何不确定性 → 失败)。
|
|
795
|
+
* 与 inspectLockState 同一套语义:alive:false 仅确证死亡 → stale 成立;
|
|
796
|
+
* alive 则需 OS identity 判断是否 reuse;无法确定 → 保守失败(不删除)。 */
|
|
797
|
+
async reProveStale(rec) {
|
|
798
|
+
let ident;
|
|
799
|
+
try {
|
|
800
|
+
ident = await this.probe.probe(rec.owner.pid);
|
|
801
|
+
}
|
|
802
|
+
catch {
|
|
803
|
+
return false;
|
|
804
|
+
}
|
|
805
|
+
// 确证死亡(ESRCH,alive:false 仅此语义)→ stale 成立
|
|
806
|
+
if (!ident.alive)
|
|
807
|
+
return true;
|
|
808
|
+
// 存活:需 identity 判断 reuse;无法可靠取得 → 保守失败
|
|
809
|
+
if (!this.probe.canGetOsIdentity() || rec.owner.osProcessStartIdentity === null || ident.osProcessStartIdentity === null) {
|
|
810
|
+
// issue #36:必须与 inspectLockState 用**同一**判据。否则首次判定「可回收」、二次验证却
|
|
811
|
+
// 判「非 stale」→ quarantine,用户拿到的仍是「二次验证失败」,等于没修。
|
|
812
|
+
const hb = await this.readHeartbeat(rec.owner.instanceId).catch(() => null);
|
|
813
|
+
return this.longExpiredReason(hb, this.now()) !== null;
|
|
814
|
+
}
|
|
815
|
+
// recorded identity 与探测不同 → PID reuse → 原 owner 已死 → stale 成立
|
|
816
|
+
if (rec.owner.osProcessStartIdentity !== ident.osProcessStartIdentity)
|
|
817
|
+
return true;
|
|
818
|
+
// 同一进程仍存活 → 非 stale
|
|
819
|
+
return false;
|
|
820
|
+
}
|
|
821
|
+
/** 心跳「长过期」判据(issue #36):达到阈值 → 返回可读原因,否则 null。
|
|
822
|
+
* heartbeat sidecar 缺失时返回 null(无从判断过期时长)——只对「确实读过 heartbeat
|
|
823
|
+
* 且它早已停更」的锁放宽,避免把「sidecar 被清掉」误判成残留。 */
|
|
824
|
+
longExpiredReason(heartbeat, now) {
|
|
825
|
+
if (heartbeat === null)
|
|
826
|
+
return null;
|
|
827
|
+
const age = now - heartbeat.heartbeatAt;
|
|
828
|
+
if (!(age >= this.longExpiredAfterMs))
|
|
829
|
+
return null; // NaN / 负值(时钟回拨)→ 保守
|
|
830
|
+
return `已过期 ${formatDuration(age)}(阈值 ${formatDuration(this.longExpiredAfterMs)})`;
|
|
831
|
+
}
|
|
832
|
+
/* ------------------------------------------------------------ diag */
|
|
833
|
+
/** 列出 locks 目录内容(诊断;不存在 → 空) */
|
|
834
|
+
async listLockFiles() {
|
|
835
|
+
try {
|
|
836
|
+
const dirs = await (this.io.listLocksDir ? this.io.listLocksDir(this.locksDir) : Promise.resolve([]));
|
|
837
|
+
return dirs;
|
|
838
|
+
}
|
|
839
|
+
catch {
|
|
840
|
+
return [];
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
// listLocksDir 可选增强:listLockFiles 使用 io.listLocksDir(若未提供 → 空数组)
|
|
845
|
+
function encode(v) {
|
|
846
|
+
return new TextEncoder().encode(JSON.stringify(v));
|
|
847
|
+
}
|
|
848
|
+
/** 毫秒 → 可读时长(诊断文案用;不追求精确,只求用户能一眼判断「多久没心跳了」) */
|
|
849
|
+
function formatDuration(ms) {
|
|
850
|
+
if (ms >= 86_400_000)
|
|
851
|
+
return `${(ms / 86_400_000).toFixed(1)} 天`;
|
|
852
|
+
if (ms >= 3_600_000)
|
|
853
|
+
return `${(ms / 3_600_000).toFixed(1)} 小时`;
|
|
854
|
+
if (ms >= 60_000)
|
|
855
|
+
return `${Math.round(ms / 60_000)} 分钟`;
|
|
856
|
+
return `${Math.round(ms / 1000)} 秒`;
|
|
857
|
+
}
|
|
858
|
+
function sleep(ms) {
|
|
859
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
860
|
+
}
|
|
861
|
+
/** 按 §8.1 把 open/io 错误分类为 LockState(EEXIST 已在 acquire 单独处理) */
|
|
862
|
+
function classifyIoError(e, what, io) {
|
|
863
|
+
const code = e.code;
|
|
864
|
+
if (code === 'EACCES' || code === 'EPERM') {
|
|
865
|
+
return { state: 'PERMISSION_ERROR', token: null, detail: `${what}: 权限/ACL 错误 (${code}),非锁占用` };
|
|
866
|
+
}
|
|
867
|
+
return { state: 'LOCK_IO_ERROR', token: null, detail: `${what}: ${e instanceof Error ? e.message : String(e)}` };
|
|
868
|
+
}
|
|
869
|
+
/** 当 release/recover 发现 ownership 已不属于本 token(异常恢复/人工修改)时抛出 */
|
|
870
|
+
export class EnvironmentLockOwnedByAnotherError extends Error {
|
|
871
|
+
constructor(msg) {
|
|
872
|
+
super(msg);
|
|
873
|
+
this.name = 'EnvironmentLockOwnedByAnotherError';
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
/** release 时 IO 失败(unlink/读 ownership)抛出;保留 activeToken 以便调用方重试 release */
|
|
877
|
+
export class EnvironmentLockIOError extends Error {
|
|
878
|
+
underlyingCause;
|
|
879
|
+
constructor(msg, underlying) {
|
|
880
|
+
super(msg);
|
|
881
|
+
this.name = 'EnvironmentLockIOError';
|
|
882
|
+
this.underlyingCause = underlying;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
/** 按分类生成用户可读的友好文案(内部诊断不进入此文案;op/reason 作为字段供日志使用)。
|
|
886
|
+
* 导出:后台调度器(自动同步/定时备份)被挡时用同一份文案写日志,避免两处文案漂移。 */
|
|
887
|
+
export const LOCK_BLOCK_MESSAGE = {
|
|
888
|
+
locked: '另一个任务正在运行,请稍后重试。',
|
|
889
|
+
blocked: '配置修改已被保护,请先处理恢复事项后再继续。',
|
|
890
|
+
unavailable: '操作暂时无法执行,请稍后重试;若持续失败请查看日志。',
|
|
891
|
+
// 必须说清「重试/重启都不会好」并给出可操作路径:否则用户只会一遍遍重试(issue #27 实测如此)。
|
|
892
|
+
// issue #31:文案承诺的「事故恢复」入口必须真的能回收残留锁——GUI 已接线
|
|
893
|
+
// (GET /recovery/status 的 lock 字段 + POST /recovery/lock/recover),两处入口都真实可达。
|
|
894
|
+
stale: '检测到上次异常退出残留的配置锁(其持有进程已不存在),操作已被阻止。'
|
|
895
|
+
+ '该锁不会自动清除,重试或重启 DSH 均无效:请在「事故恢复」中点击「回收残留锁」,'
|
|
896
|
+
+ '或运行 dsh-config-manager recover-stale-lock 回收后再重试。',
|
|
897
|
+
};
|
|
898
|
+
/** 分类的**简短**文案(单行、可用于表格单元格/迁移历史摘要/日志前缀)。
|
|
899
|
+
* 与 LOCK_BLOCK_MESSAGE 同源同分类:长文案给「需要完整指引」的场景(423 响应/告警日志),
|
|
900
|
+
* 短文案给「一行放不下长句」的场景。分开定义避免任一处再自造文案(issue #31 的漂移根因)。 */
|
|
901
|
+
export const LOCK_BLOCK_BRIEF = {
|
|
902
|
+
locked: '环境锁被另一项任务占用',
|
|
903
|
+
blocked: '配置修改已被保护,需先处理恢复事项',
|
|
904
|
+
unavailable: '环境锁暂时不可用',
|
|
905
|
+
// stale 必须点出「残留」——否则用户会像 issue #31 那样等 9 天(57 次静默跳过)。
|
|
906
|
+
stale: '残留配置锁(持有进程已不存在),需先回收',
|
|
907
|
+
};
|
|
908
|
+
/** destructive 必须成功获取 Environment Lock;否则抛此错(被另一进程/操作持有,或锁不可用)。
|
|
909
|
+
* 携带 op 与 reason 供内部日志诊断;.message 恒为用户可读的友好文案(不暴露锁/op/路径)。 */
|
|
910
|
+
export class EnvironmentLockUnavailableError extends Error {
|
|
911
|
+
reason;
|
|
912
|
+
op;
|
|
913
|
+
/**
|
|
914
|
+
* 内部诊断(非敏感):占用方 op/hostname 或 stale 判定依据。**只进日志**,
|
|
915
|
+
* 绝不进入 .message(用户文案里不含 op/路径/主机名)。
|
|
916
|
+
*/
|
|
917
|
+
detail;
|
|
918
|
+
constructor(op, reason = 'locked', detail) {
|
|
919
|
+
super(LOCK_BLOCK_MESSAGE[reason]);
|
|
920
|
+
this.name = 'EnvironmentLockUnavailableError';
|
|
921
|
+
this.reason = reason;
|
|
922
|
+
this.op = op;
|
|
923
|
+
this.detail = detail;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
/** 便捷:以 token 授权当前持有(供 nested operation 判断复用)—— 等价于 manager.validate 的纯函数形态 */
|
|
927
|
+
export function isTokenValid(manager, ctx) {
|
|
928
|
+
return ctx !== undefined && typeof ctx === 'object' && manager.validate(ctx.token);
|
|
929
|
+
}
|
|
930
|
+
//# sourceMappingURL=env-lock.js.map
|