@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,999 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 跨进程环境锁 primitive 测试(Phase 2:Cross-process Lock)。
|
|
3
|
+
*
|
|
4
|
+
* 覆盖基线:CROSS_PROCESS_LOCK_DESIGN.md Rev 3 §11(用例 1–18)+ §11.1b(用例 19–25,
|
|
5
|
+
* BLOCKER 4 operation-scoped token)以及 §11.2/§11.3 的 withMutationLock/故障注入要点(用例 26)。
|
|
6
|
+
*
|
|
7
|
+
* 测试用 node:test + node:assert/strict(零第三方依赖),风格对齐 atomic-write.test.ts:
|
|
8
|
+
* - 通过可注入 EnvLockIo / ProcessIdentityProbe / 时钟(now)驱动(对齐 AtomicIo 模式)。
|
|
9
|
+
* - 跨进程用例(#3 / #16 / #17)用子进程 `node child.mjs` 动态 import 真实 env-lock.ts 验证,
|
|
10
|
+
* 父进程与子进程共享同一 locks 目录(真实文件级互斥)。
|
|
11
|
+
*
|
|
12
|
+
* 只测试,不修改实现。若某用例暴露实现 bug,以诊断注释记录 interleaving,供 Lead 修复。
|
|
13
|
+
*/
|
|
14
|
+
import test from 'node:test';
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import fs from 'node:fs/promises';
|
|
17
|
+
import fssync from 'node:fs';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
21
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
22
|
+
import {
|
|
23
|
+
EnvironmentLockManager,
|
|
24
|
+
EnvironmentLockIOError,
|
|
25
|
+
EnvironmentLockOwnedByAnotherError,
|
|
26
|
+
EnvironmentLockUnavailableError,
|
|
27
|
+
runWithMutationLock,
|
|
28
|
+
withMutationLock,
|
|
29
|
+
LOCK_SCHEMA_VERSION,
|
|
30
|
+
OWNERSHIP_FILE,
|
|
31
|
+
HEARTBEAT_PREFIX,
|
|
32
|
+
RECOVERING_PREFIX,
|
|
33
|
+
type LockOwnershipRecord,
|
|
34
|
+
type ProcessIdentityProbe,
|
|
35
|
+
} from './env-lock.ts';
|
|
36
|
+
|
|
37
|
+
/* ---------------------------------------------------------------- 工具 */
|
|
38
|
+
|
|
39
|
+
const here = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url));
|
|
40
|
+
/** env-lock.ts 绝对路径(子进程动态 import 用 file:// URL) */
|
|
41
|
+
const ENV_LOCK_ABS = path.resolve(here, 'env-lock.ts');
|
|
42
|
+
|
|
43
|
+
function tmp(t: test.TestContext): string {
|
|
44
|
+
const dir = fssync.mkdtempSync(path.join(os.tmpdir(), 'env-lock-'));
|
|
45
|
+
t.after(() => fssync.rmSync(dir, { recursive: true, force: true }));
|
|
46
|
+
return dir;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sleep(ms: number): Promise<void> {
|
|
50
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const sleepReal = sleep;
|
|
54
|
+
|
|
55
|
+
async function readText(p: string): Promise<string> {
|
|
56
|
+
return new TextDecoder().decode(await fs.readFile(p));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 读取并解析环境锁 ownership 文件(不存在/损坏 → null) */
|
|
60
|
+
async function readOwnership(locksDir: string): Promise<unknown> {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(await readText(path.join(locksDir, OWNERSHIP_FILE)));
|
|
63
|
+
} catch { return null; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function heartbeatFile(instanceId: string): string {
|
|
67
|
+
return `${HEARTBEAT_PREFIX}${instanceId}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 等待某 instanceId 的 heartbeat sidecar 落盘(acquire 后首写为异步,需等待才能确保 inspect 判 fresh) */
|
|
71
|
+
async function waitHeartbeat(locksDir: string, instanceId: string): Promise<void> {
|
|
72
|
+
const p = path.join(locksDir, heartbeatFile(instanceId));
|
|
73
|
+
const t0 = Date.now();
|
|
74
|
+
while (!fssync.existsSync(p)) {
|
|
75
|
+
if (Date.now() - t0 > 4000) throw new Error(`heartbeat sidecar 未在预期时间内落盘: ${p}`);
|
|
76
|
+
await sleepReal(20);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 注入面包装的真实 IO(基于 node:fs/promises),支持按路径故障注入 + rename 后钩子 */
|
|
81
|
+
function makeIo(): {
|
|
82
|
+
io: import('./env-lock.ts').EnvLockIo;
|
|
83
|
+
failOpenWhen(p: (p: string) => boolean, code?: string): void;
|
|
84
|
+
failUnlinkWhen(p: (p: string) => boolean): void;
|
|
85
|
+
failReadWhen(p: (p: string) => boolean): void;
|
|
86
|
+
hookRename(fn: (a: string, b: string) => void | Promise<void>): void;
|
|
87
|
+
clear(): void;
|
|
88
|
+
} {
|
|
89
|
+
const openFaults: Array<{ p: (p: string) => boolean; code?: string }> = [];
|
|
90
|
+
const unlinkFaults: Array<(p: string) => boolean> = [];
|
|
91
|
+
const readFaults: Array<(p: string) => boolean> = [];
|
|
92
|
+
const renameHooks: Array<(a: string, b: string) => void | Promise<void>> = [];
|
|
93
|
+
const io: import('./env-lock.ts').EnvLockIo = {
|
|
94
|
+
async mkdir(d, o) { await fs.mkdir(d, o); },
|
|
95
|
+
async open(p, flag, mode) {
|
|
96
|
+
for (const f of openFaults) if (f.p(p)) {
|
|
97
|
+
const e = new Error('injected open fault') as NodeJS.ErrnoException;
|
|
98
|
+
if (f.code !== undefined) e.code = f.code;
|
|
99
|
+
throw e;
|
|
100
|
+
}
|
|
101
|
+
return fs.open(p, flag as never, mode) as never;
|
|
102
|
+
},
|
|
103
|
+
async rename(a, b) { await fs.rename(a, b); for (const h of renameHooks) await h(a, b); },
|
|
104
|
+
async unlink(p) {
|
|
105
|
+
for (const f of unlinkFaults) if (f(p)) {
|
|
106
|
+
const e = new Error('injected unlink fault') as NodeJS.ErrnoException;
|
|
107
|
+
e.code = 'EPERM';
|
|
108
|
+
throw e;
|
|
109
|
+
}
|
|
110
|
+
await fs.unlink(p);
|
|
111
|
+
},
|
|
112
|
+
async stat(p) { try { return await fs.stat(p); } catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null; throw e; } },
|
|
113
|
+
async readFileText(p) {
|
|
114
|
+
for (const f of readFaults) if (f(p)) throw new Error('injected read fault');
|
|
115
|
+
return (await fs.readFile(p, 'utf8')).toString();
|
|
116
|
+
},
|
|
117
|
+
async lstat(p) { try { return await fs.lstat(p); } catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null; throw e; } },
|
|
118
|
+
async listLocksDir(d) { return fs.readdir(d); },
|
|
119
|
+
};
|
|
120
|
+
return {
|
|
121
|
+
io,
|
|
122
|
+
failOpenWhen(p, code) { openFaults.push({ p, code }); },
|
|
123
|
+
failUnlinkWhen(p) { unlinkFaults.push(p); },
|
|
124
|
+
failReadWhen(p) { readFaults.push(p); },
|
|
125
|
+
hookRename(fn) { renameHooks.push(fn); },
|
|
126
|
+
clear() { openFaults.length = 0; unlinkFaults.length = 0; readFaults.length = 0; },
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 可注入进程探测桩 */
|
|
131
|
+
function makeProbe(): {
|
|
132
|
+
probe: ProcessIdentityProbe;
|
|
133
|
+
get calls(): number;
|
|
134
|
+
canOs(b: boolean): void;
|
|
135
|
+
respond(f: (pid: number) => { alive: boolean; osProcessStartIdentity: string | null }): void;
|
|
136
|
+
} {
|
|
137
|
+
let canOs = true;
|
|
138
|
+
let impl: (pid: number) => { alive: boolean; osProcessStartIdentity: string | null } =
|
|
139
|
+
() => ({ alive: false, osProcessStartIdentity: null });
|
|
140
|
+
let n = 0;
|
|
141
|
+
const probe: ProcessIdentityProbe = {
|
|
142
|
+
async probe(pid) { n += 1; return impl(pid); },
|
|
143
|
+
canGetOsIdentity() { return canOs; },
|
|
144
|
+
};
|
|
145
|
+
return {
|
|
146
|
+
probe,
|
|
147
|
+
get calls() { return n; },
|
|
148
|
+
canOs(b) { canOs = b; },
|
|
149
|
+
respond(f) { impl = f; },
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 可推进时钟 */
|
|
154
|
+
function makeClock(start = 5_000_000): { clock: () => number; advance(ms: number): void } {
|
|
155
|
+
let t = start;
|
|
156
|
+
return { clock: () => t, advance: (ms) => { t += ms; } };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 写入一份 environment.lock(模拟 external owner,供 inspect/recover 用) */
|
|
160
|
+
async function seedOwnership(
|
|
161
|
+
locksDir: string,
|
|
162
|
+
opts: { instanceId: string; pid?: number; osIdentity?: string | null; op?: string; lockVersion?: string },
|
|
163
|
+
): Promise<void> {
|
|
164
|
+
const rec: LockOwnershipRecord = {
|
|
165
|
+
schemaVersion: LOCK_SCHEMA_VERSION,
|
|
166
|
+
owner: {
|
|
167
|
+
instanceId: opts.instanceId,
|
|
168
|
+
instanceStartedAt: 1000,
|
|
169
|
+
pid: opts.pid ?? 424242,
|
|
170
|
+
hostname: 'seed-host',
|
|
171
|
+
osProcessStartIdentity: opts.osIdentity ?? null,
|
|
172
|
+
},
|
|
173
|
+
op: opts.op ?? 'seed',
|
|
174
|
+
target: 'seed',
|
|
175
|
+
acquiredAt: 1,
|
|
176
|
+
lockVersion: opts.lockVersion ?? '1.0.0',
|
|
177
|
+
journalId: null,
|
|
178
|
+
};
|
|
179
|
+
await fs.writeFile(path.join(locksDir, OWNERSHIP_FILE), JSON.stringify(rec));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 写入一份 heartbeat sidecar */
|
|
183
|
+
async function seedHeartbeat(locksDir: string, instanceId: string, heartbeatAt: number, seq = 1): Promise<void> {
|
|
184
|
+
await fs.writeFile(
|
|
185
|
+
path.join(locksDir, heartbeatFile(instanceId)),
|
|
186
|
+
JSON.stringify({ ownerInstanceId: instanceId, heartbeatAt, seq }),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* ---------------------------------------------------------------- helper:跨进程 */
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 写子进程脚本并 spawn `node` 执行。子进程以纯 JS(.mjs)+ 动态 import 加载真实 env-lock.ts,
|
|
194
|
+
* 在 node ≥22.18(type stripping 默认开启)下可直接 import .ts 模块 —— 已在环境验证。
|
|
195
|
+
*/
|
|
196
|
+
function spawnNode(body: string, filePath: string, extraEnv: Record<string, string> = {}): {
|
|
197
|
+
child: ChildProcess;
|
|
198
|
+
getStdout(): string;
|
|
199
|
+
getStderr(): string;
|
|
200
|
+
exit: Promise<number>;
|
|
201
|
+
} {
|
|
202
|
+
const child = spawn(process.execPath, [filePath], {
|
|
203
|
+
env: { ...process.env, LOCK_ABS: ENV_LOCK_ABS, ...extraEnv },
|
|
204
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
205
|
+
});
|
|
206
|
+
let so = '';
|
|
207
|
+
let se = '';
|
|
208
|
+
child.stdout?.on('data', (d) => { so += d; });
|
|
209
|
+
child.stderr?.on('data', (d) => { se += d; });
|
|
210
|
+
const exit = new Promise<number>((res) => { child.on('close', (c) => res(c ?? -1)); });
|
|
211
|
+
return { child, getStdout: () => so, getStderr: () => se, exit };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function waitForSub(get: () => string, sub: string, timeoutMs = 10_000): Promise<void> {
|
|
215
|
+
const t0 = Date.now();
|
|
216
|
+
for (;;) {
|
|
217
|
+
if (get().includes(sub)) return;
|
|
218
|
+
if (Date.now() - t0 > timeoutMs) throw new Error(`超时等待「${sub}", 已见: ${get().slice(0, 500)}`);
|
|
219
|
+
await sleepReal(30);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function childHeader(): string {
|
|
224
|
+
return `import { pathToFileURL } from 'node:url';
|
|
225
|
+
const { EnvironmentLockManager } = await import(pathToFileURL(process.env.LOCK_ABS).href);
|
|
226
|
+
`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* ================================================================ Design §11 */
|
|
230
|
+
|
|
231
|
+
test('§11.1-c1 `open(wx)` 独占创建:并发 acquire 只有一个成功(多 manager 同目录)', async (t) => {
|
|
232
|
+
const locksDir = tmp(t);
|
|
233
|
+
const n = 6;
|
|
234
|
+
const mgrs = Array.from({ length: n }, () => new EnvironmentLockManager({ locksDir }));
|
|
235
|
+
const results = await Promise.all(mgrs.map((m) => m.acquire({ op: 'concurrent' })));
|
|
236
|
+
const winners = results.filter((r) => r.state === 'ACQUIRED');
|
|
237
|
+
assert.equal(winners.length, 1, `必须恰好一个 ACQUIRED,实际 ${results.map((r) => r.state).join(',')}`);
|
|
238
|
+
const losers = results.filter((r) => r.state !== 'ACQUIRED');
|
|
239
|
+
assert.equal(losers.length, n - 1);
|
|
240
|
+
// 唯一持有者释放后可再 acquire
|
|
241
|
+
await mgrs[results.findIndex((r) => r.state === 'ACQUIRED')]!.release(winners[0]!.token!);
|
|
242
|
+
const againMgr = new EnvironmentLockManager({ locksDir });
|
|
243
|
+
const again = await againMgr.acquire({ op: 're' });
|
|
244
|
+
assert.equal(again.state, 'ACQUIRED');
|
|
245
|
+
await againMgr.release(again.token!);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('§11.1-c2 不存在 exists→write 竞态:并发双 acquire 至少失败其一', async (t) => {
|
|
249
|
+
const locksDir = tmp(t);
|
|
250
|
+
const a = new EnvironmentLockManager({ locksDir });
|
|
251
|
+
const b = new EnvironmentLockManager({ locksDir });
|
|
252
|
+
const [ra, rb] = await Promise.all([a.acquire({ op: 'x' }), b.acquire({ op: 'y' })]);
|
|
253
|
+
assert.notEqual(ra.state === 'ACQUIRED', rb.state === 'ACQUIRED', '双 acquire 不能同时成功(源码无 exists→write)');
|
|
254
|
+
const winner = ra.state === 'ACQUIRED' ? a : b;
|
|
255
|
+
const token = ra.state === 'ACQUIRED' ? ra.token! : rb.token!;
|
|
256
|
+
await winner.release(token);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('§11.1-c3 持句柄 = 持锁(child process):父 acquire 后,child 同 locksDir acquire → 失败', async (t) => {
|
|
260
|
+
const dir = tmp(t);
|
|
261
|
+
const locksDir = path.join(dir, 'locks');
|
|
262
|
+
const parent = new EnvironmentLockManager({ locksDir });
|
|
263
|
+
const pres = await parent.acquire({ op: 'parent-hold' });
|
|
264
|
+
assert.equal(pres.state, 'ACQUIRED');
|
|
265
|
+
|
|
266
|
+
const childScript = childHeader() + `
|
|
267
|
+
const mgr = new EnvironmentLockManager({ locksDir: process.env.LOCKS_DIR, heartbeatIntervalMs: 100000 });
|
|
268
|
+
const res = await mgr.acquire({ op: 'child' });
|
|
269
|
+
console.log('RESULT ' + res.state + ' | ' + (res.detail ?? ''));
|
|
270
|
+
process.exit(1); // 期望被拒:非 0 指示「未获得锁」;父进程根据 RESULT 行判定
|
|
271
|
+
`;
|
|
272
|
+
const file = path.join(dir, 'child3.mjs');
|
|
273
|
+
await fs.writeFile(file, childScript);
|
|
274
|
+
const h = spawnNode(childScript, file, { LOCKS_DIR: locksDir });
|
|
275
|
+
const code = await h.exit;
|
|
276
|
+
assert.equal(code, 1, 'child 应因未获得锁以非 0 退出');
|
|
277
|
+
assert.ok(h.getStdout().includes('RESULT '), `child 输出: ${h.getStdout()}`);
|
|
278
|
+
assert.ok(!/RESULT ACQUIRED/.test(h.getStdout()), 'child 不应 ACQUIRED(父持锁)');
|
|
279
|
+
const st = h.getStdout().match(/RESULT (\S+)/)?.[1];
|
|
280
|
+
assert.ok(st === 'LOCKED' || st === 'UNKNOWN_STATE', `child 被挡状态: ${st}`);
|
|
281
|
+
await parent.release(pres.token!);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('§11.1-c4 release:close→unlink;unlink 失败抛 EnvironmentLockIOError 且保留 activeToken(可重试)', async (t) => {
|
|
285
|
+
const locksDir = tmp(t);
|
|
286
|
+
const ctl = makeIo();
|
|
287
|
+
const mgr = new EnvironmentLockManager({ locksDir, io: ctl.io });
|
|
288
|
+
const res = await mgr.acquire({ op: 'u' });
|
|
289
|
+
assert.equal(res.state, 'ACQUIRED');
|
|
290
|
+
const token = res.token!;
|
|
291
|
+
// 注入 unlink(environment.lock) 失败
|
|
292
|
+
ctl.failUnlinkWhen((p) => p === mgr.ownershipPath);
|
|
293
|
+
await assert.rejects(
|
|
294
|
+
() => mgr.release(token),
|
|
295
|
+
(e) => e instanceof EnvironmentLockIOError,
|
|
296
|
+
);
|
|
297
|
+
assert.equal(mgr.isHolding, true, 'unlink 失败后必须保留 activeToken(可重试,不卡死磁盘锁)');
|
|
298
|
+
assert.ok(fssync.existsSync(mgr.ownershipPath), '锁文件仍在磁盘(未被误删)');
|
|
299
|
+
// 恢复后重试 release 成功
|
|
300
|
+
ctl.clear();
|
|
301
|
+
await mgr.release(token);
|
|
302
|
+
assert.equal(mgr.isHolding, false);
|
|
303
|
+
assert.equal(fssync.existsSync(mgr.ownershipPath), false, 'release 后 ownership 被删除');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('§11.1-c5 owner metadata 写入/读回一致', async (t) => {
|
|
307
|
+
const locksDir = tmp(t);
|
|
308
|
+
const mgr = new EnvironmentLockManager({
|
|
309
|
+
locksDir,
|
|
310
|
+
op: 'import',
|
|
311
|
+
target: 'executeImportPlan',
|
|
312
|
+
lockVersion: '0.0.0-test',
|
|
313
|
+
});
|
|
314
|
+
const res = await mgr.acquire({ op: 'import', target: 'executeImportPlan' });
|
|
315
|
+
assert.equal(res.state, 'ACQUIRED');
|
|
316
|
+
const owner = (await readOwnershipByMgr(mgr)) as LockOwnershipRecord;
|
|
317
|
+
assert.equal(owner.schemaVersion, LOCK_SCHEMA_VERSION);
|
|
318
|
+
assert.equal(owner.owner.instanceId, res.token!.instanceId);
|
|
319
|
+
assert.equal(owner.owner.pid, process.pid);
|
|
320
|
+
assert.equal(owner.owner.hostname, os.hostname());
|
|
321
|
+
assert.equal(typeof owner.owner.instanceStartedAt, 'number');
|
|
322
|
+
assert.equal(owner.op, 'import');
|
|
323
|
+
assert.equal(owner.target, 'executeImportPlan');
|
|
324
|
+
assert.equal(owner.lockVersion, '0.0.0-test');
|
|
325
|
+
assert.ok(owner.owner.osProcessStartIdentity === null || typeof owner.owner.osProcessStartIdentity === 'string');
|
|
326
|
+
await mgr.release(res.token!);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
/** 直接读 manager 的 ownership 文件并解析(返回 null 若缺失) */
|
|
330
|
+
async function readOwnershipByMgr(mgr: EnvironmentLockManager): Promise<LockOwnershipRecord | null> {
|
|
331
|
+
try { return JSON.parse(await readText(mgr.ownershipPath)) as LockOwnershipRecord; }
|
|
332
|
+
catch { return null; }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
test('§11.1-c6 heartbeat sidecar 更新不替换 environment.lock(inode/内容不变)', async (t) => {
|
|
336
|
+
const locksDir = tmp(t);
|
|
337
|
+
const ctl = makeIo();
|
|
338
|
+
const mgr = new EnvironmentLockManager({ locksDir, io: ctl.io, heartbeatIntervalMs: 50 });
|
|
339
|
+
const res = await mgr.acquire({ op: 'hb' });
|
|
340
|
+
assert.equal(res.state, 'ACQUIRED');
|
|
341
|
+
const id = res.token!.instanceId;
|
|
342
|
+
// 等待首个 heartbeat sidecar 落盘
|
|
343
|
+
let sawSb = false;
|
|
344
|
+
for (let i = 0; i < 100; i++) {
|
|
345
|
+
const files = await mgr.listLockFiles();
|
|
346
|
+
if (files.some((n) => n === heartbeatFile(id))) { sawSb = true; break; }
|
|
347
|
+
await sleepReal(20);
|
|
348
|
+
}
|
|
349
|
+
assert.ok(sawSb, 'heartbeat sidecar 应已创建');
|
|
350
|
+
const ownerCtl = ctl.io; // real fs
|
|
351
|
+
const beforeText = await readText(path.join(locksDir, OWNERSHIP_FILE));
|
|
352
|
+
// 等若干心跳周期(≥3 tick),期间只更新 sidecar
|
|
353
|
+
const hbPath = path.join(locksDir, heartbeatFile(id));
|
|
354
|
+
const seqProbe: number[] = [];
|
|
355
|
+
for (let i = 0; i < 3; i++) {
|
|
356
|
+
await sleepReal(70);
|
|
357
|
+
seqProbe.push(JSON.parse(await readText(hbPath)).seq as number);
|
|
358
|
+
}
|
|
359
|
+
// ownership 内容绝对不变(无 rename/replace —— 若有 atomicWriteFile 替换 ownership 则内容会变/文件被换)
|
|
360
|
+
const afterText = await readText(path.join(locksDir, OWNERSHIP_FILE));
|
|
361
|
+
assert.equal(afterText, beforeText, 'heartbeat 更新不得替换 environment.lock 内容');
|
|
362
|
+
// POSIX 下断言 inode 不变
|
|
363
|
+
if (process.platform !== 'win32') {
|
|
364
|
+
const ino1 = (await fs.stat(path.join(locksDir, OWNERSHIP_FILE))).ino;
|
|
365
|
+
const ino2 = (await fs.stat(path.join(locksDir, OWNERSHIP_FILE))).ino;
|
|
366
|
+
assert.equal(ino1, ino2, 'ownership inode 必须稳定');
|
|
367
|
+
}
|
|
368
|
+
assert.ok(seqProbe[seqProbe.length - 1]! > seqProbe[0]!, `heartbeat seq 应递增: ${seqProbe.join(',')}`);
|
|
369
|
+
// heartbeat sidecar 内容绑定 ownerInstanceId
|
|
370
|
+
const hb = JSON.parse(await readText(hbPath)) as { ownerInstanceId: string };
|
|
371
|
+
assert.equal(hb.ownerInstanceId, id);
|
|
372
|
+
await mgr.release(res.token!);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test('§11.1-c7 old heartbeat sidecar 不影响新 owner(不同 instanceId 文件名隔离)', async (t) => {
|
|
376
|
+
const locksDir = tmp(t);
|
|
377
|
+
// A 持有并释放;期间人为残留一个其它 owner 的 heartbeat
|
|
378
|
+
const a = new EnvironmentLockManager({ locksDir });
|
|
379
|
+
const ra = await a.acquire({ op: 'a' });
|
|
380
|
+
const idA = ra.token!.instanceId;
|
|
381
|
+
await fs.writeFile(path.join(locksDir, heartbeatFile('foreign-owner')), JSON.stringify({
|
|
382
|
+
ownerInstanceId: 'foreign-owner', heartbeatAt: 0, seq: 1,
|
|
383
|
+
}));
|
|
384
|
+
// A 释放只删自己的 heartbeat,不得触碰 foreign 的
|
|
385
|
+
await a.release(ra.token!);
|
|
386
|
+
assert.equal(fssync.existsSync(path.join(locksDir, heartbeatFile(idA))), false, 'A 的 heartbeat 应被清理');
|
|
387
|
+
assert.ok(fssync.existsSync(path.join(locksDir, heartbeatFile('foreign-owner'))), '其它 owner 的 heartbeat 不得被误删');
|
|
388
|
+
|
|
389
|
+
// 新 owner B acquire:不同 instanceId → 文件名不同
|
|
390
|
+
const b = new EnvironmentLockManager({ locksDir });
|
|
391
|
+
const rb = await b.acquire({ op: 'b' });
|
|
392
|
+
const idB = rb.token!.instanceId;
|
|
393
|
+
assert.notEqual(idB, idA);
|
|
394
|
+
// 模拟 B 的锁 + 新鲜 heartbeat:inspect 只依据 B 自己的 sidecar,忽略 foreign 残留
|
|
395
|
+
await waitHeartbeat(locksDir, idB); // 等首写 heartbeat 落盘,避免异步 race 误判 expired
|
|
396
|
+
const insp = await b.inspectLockState();
|
|
397
|
+
assert.equal(insp.state, 'LOCKED', 'fresh heartbeat(新 owner 自己的 sidecar)→ LOCKED,不受旧的 foreign heartbeat 影响');
|
|
398
|
+
await b.release(rb.token!);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test('§11.1-c8 release instanceId mismatch → 抛 EnvironmentLockOwnedByAnotherError + 不 unlink', async (t) => {
|
|
402
|
+
const locksDir = tmp(t);
|
|
403
|
+
const mgr = new EnvironmentLockManager({ locksDir });
|
|
404
|
+
const res = await mgr.acquire({ op: 'rel' });
|
|
405
|
+
const token = res.token!;
|
|
406
|
+
// 外部篡改 ownership.instanceId(模拟异常恢复/人工修改/他方接管)
|
|
407
|
+
const owner = (await readOwnershipByMgr(mgr))!;
|
|
408
|
+
owner.owner.instanceId = 'EVIL-OTHER';
|
|
409
|
+
await fs.writeFile(mgr.ownershipPath, JSON.stringify(owner));
|
|
410
|
+
await assert.rejects(
|
|
411
|
+
() => mgr.release(token),
|
|
412
|
+
(e) => e instanceof EnvironmentLockOwnedByAnotherError,
|
|
413
|
+
);
|
|
414
|
+
assert.ok(fssync.existsSync(mgr.ownershipPath), 'instanceId 不匹配必须拒绝 unlink');
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test('§11.1-c9 heartbeat 续期(注入时钟)+ 持续写 sidecar', async (t) => {
|
|
418
|
+
const locksDir = tmp(t);
|
|
419
|
+
const clk = makeClock();
|
|
420
|
+
const ctl = makeIo();
|
|
421
|
+
const mgr = new EnvironmentLockManager({ locksDir, io: ctl.io, now: clk.clock, heartbeatIntervalMs: 60 });
|
|
422
|
+
const res = await mgr.acquire({ op: 'lease' });
|
|
423
|
+
assert.equal(res.state, 'ACQUIRED');
|
|
424
|
+
const id = res.token!.instanceId;
|
|
425
|
+
const hbPath = path.join(locksDir, heartbeatFile(id));
|
|
426
|
+
// 等待首写
|
|
427
|
+
for (let i = 0; i < 100; i++) { if (fssync.existsSync(hbPath)) break; await sleepReal(20); }
|
|
428
|
+
const first = JSON.parse(await readText(hbPath)) as { heartbeatAt: number; seq: number };
|
|
429
|
+
assert.equal(first.heartbeatAt, clk.clock(), '首写 heartbeatAt 使用注入时钟');
|
|
430
|
+
// 推进时钟 + 等待若干 tick → heartbeatAt 跟随推进后的时钟(续期)
|
|
431
|
+
clk.advance(5000);
|
|
432
|
+
await sleepReal(250);
|
|
433
|
+
const last = JSON.parse(await readText(hbPath)) as { heartbeatAt: number; seq: number };
|
|
434
|
+
assert.ok(last.seq > first.seq, '续期后 seq 递增');
|
|
435
|
+
assert.ok(last.heartbeatAt >= clk.clock() - 60, '续期 heartbeatAt 反映推进后的时钟');
|
|
436
|
+
await mgr.release(res.token!);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test('§11.1-c10 heartbeat write failure → degraded 标记 + 不中断 + 无自动删除', async (t) => {
|
|
440
|
+
const locksDir = tmp(t);
|
|
441
|
+
const failures: unknown[] = [];
|
|
442
|
+
const ctl = makeIo();
|
|
443
|
+
const mgr = new EnvironmentLockManager({
|
|
444
|
+
locksDir,
|
|
445
|
+
io: ctl.io,
|
|
446
|
+
heartbeatIntervalMs: 300,
|
|
447
|
+
onHeartbeatWriteFailure: (e) => failures.push(e),
|
|
448
|
+
});
|
|
449
|
+
const res = await mgr.acquire({ op: 'hb-fail' });
|
|
450
|
+
assert.equal(res.state, 'ACQUIRED');
|
|
451
|
+
const id = res.token!.instanceId;
|
|
452
|
+
const hbPath = path.join(locksDir, heartbeatFile(id));
|
|
453
|
+
// 等待首写落盘
|
|
454
|
+
for (let i = 0; i < 100; i++) { if (fssync.existsSync(hbPath)) break; await sleepReal(20); }
|
|
455
|
+
// 用同路径目录替换 sidecar → 下一次 atomicWriteFile 的 rename 失败(sidecar 更新失败)
|
|
456
|
+
await fs.rm(hbPath, { force: true });
|
|
457
|
+
await fs.mkdir(hbPath);
|
|
458
|
+
const ownerBefore = await readText(path.join(locksDir, OWNERSHIP_FILE));
|
|
459
|
+
await sleepReal(900); // 覆盖一个心跳周期 + atomicWriteFile 重试窗口
|
|
460
|
+
assert.ok(failures.length > 0, 'heartbeat 写失败应触发 onHeartbeatWriteFailure(degraded 标记)');
|
|
461
|
+
assert.equal(mgr.isHolding, true, 'heartbeat 失败不得中断当前 mutation(锁仍持有)');
|
|
462
|
+
const ownerAfter = await readText(path.join(locksDir, OWNERSHIP_FILE));
|
|
463
|
+
assert.equal(ownerAfter, ownerBefore, 'heartbeat 失败不得替换/删除 ownership(无自动 takeover)');
|
|
464
|
+
// 清理:恢复为可删除状态后仍能正常 release
|
|
465
|
+
await fs.rmdir(hbPath);
|
|
466
|
+
await mgr.release(res.token!);
|
|
467
|
+
assert.equal(mgr.isHolding, false);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test('§11.1-c11 stale 判定状态表(可注入 probe)', async (t) => {
|
|
471
|
+
const locksDir = tmp(t);
|
|
472
|
+
const clk = makeClock();
|
|
473
|
+
// fresh heartbeat → LOCKED
|
|
474
|
+
{
|
|
475
|
+
await seedOwnership(locksDir, { instanceId: 'fresh', pid: 1111, osIdentity: 'osX' });
|
|
476
|
+
await seedHeartbeat(locksDir, 'fresh', clk.clock());
|
|
477
|
+
await fs.rm(path.join(locksDir, heartbeatFile('fresh')), { force: true });
|
|
478
|
+
// 重新写一份 fresh(让 heartbeatAt = clk.clock())
|
|
479
|
+
await seedHeartbeat(locksDir, 'fresh', clk.clock());
|
|
480
|
+
const p = makeProbe();
|
|
481
|
+
const m = new EnvironmentLockManager({ locksDir, now: clk.clock, probe: p.probe, staleAfterMs: 1000 });
|
|
482
|
+
const s = await m.inspectLockState();
|
|
483
|
+
assert.equal(s.state, 'LOCKED', 'heartbeat fresh → LOCKED');
|
|
484
|
+
await fs.rm(path.join(locksDir, OWNERSHIP_FILE), { force: true });
|
|
485
|
+
await fs.rm(path.join(locksDir, heartbeatFile('fresh')), { force: true });
|
|
486
|
+
}
|
|
487
|
+
// expired + PID 不存在 → STALE
|
|
488
|
+
{
|
|
489
|
+
await seedOwnership(locksDir, { instanceId: 'dead', pid: 2222, osIdentity: 'osY' });
|
|
490
|
+
await seedHeartbeat(locksDir, 'dead', clk.clock() - 2000);
|
|
491
|
+
const p = makeProbe(); p.respond(() => ({ alive: false, osProcessStartIdentity: null }));
|
|
492
|
+
const m = new EnvironmentLockManager({ locksDir, now: clk.clock, probe: p.probe, staleAfterMs: 1000 });
|
|
493
|
+
assert.equal((await m.inspectLockState()).state, 'STALE_LOCK_DETECTED');
|
|
494
|
+
await fs.rm(path.join(locksDir, OWNERSHIP_FILE), { force: true });
|
|
495
|
+
await fs.rm(path.join(locksDir, heartbeatFile('dead')), { force: true });
|
|
496
|
+
}
|
|
497
|
+
// expired + PID 存活 + identity 不同 → STALE(PID reuse)
|
|
498
|
+
{
|
|
499
|
+
await seedOwnership(locksDir, { instanceId: 'reuse', pid: 3333, osIdentity: 'osOld' });
|
|
500
|
+
await seedHeartbeat(locksDir, 'reuse', clk.clock() - 2000);
|
|
501
|
+
const p = makeProbe(); p.respond(() => ({ alive: true, osProcessStartIdentity: 'osNew' }));
|
|
502
|
+
const m = new EnvironmentLockManager({ locksDir, now: clk.clock, probe: p.probe, staleAfterMs: 1000 });
|
|
503
|
+
assert.equal((await m.inspectLockState()).state, 'STALE_LOCK_DETECTED', 'PID reuse → STALE');
|
|
504
|
+
await fs.rm(path.join(locksDir, OWNERSHIP_FILE), { force: true });
|
|
505
|
+
await fs.rm(path.join(locksDir, heartbeatFile('reuse')), { force: true });
|
|
506
|
+
}
|
|
507
|
+
// expired + PID 存活 + identity 相同 → LOCKED(owner alive / heartbeat degraded)
|
|
508
|
+
{
|
|
509
|
+
await seedOwnership(locksDir, { instanceId: 'alive', pid: 4444, osIdentity: 'osSame' });
|
|
510
|
+
await seedHeartbeat(locksDir, 'alive', clk.clock() - 2000);
|
|
511
|
+
const p = makeProbe(); p.respond(() => ({ alive: true, osProcessStartIdentity: 'osSame' }));
|
|
512
|
+
const m = new EnvironmentLockManager({ locksDir, now: clk.clock, probe: p.probe, staleAfterMs: 1000 });
|
|
513
|
+
assert.equal((await m.inspectLockState()).state, 'LOCKED', 'alive + identity 同 → LOCKED');
|
|
514
|
+
await fs.rm(path.join(locksDir, OWNERSHIP_FILE), { force: true });
|
|
515
|
+
await fs.rm(path.join(locksDir, heartbeatFile('alive')), { force: true });
|
|
516
|
+
}
|
|
517
|
+
// probe 失败 / 无法确定 → UNKNOWN_STATE
|
|
518
|
+
{
|
|
519
|
+
await seedOwnership(locksDir, { instanceId: 'unk', pid: 5555, osIdentity: 'osU' });
|
|
520
|
+
await seedHeartbeat(locksDir, 'unk', clk.clock() - 2000);
|
|
521
|
+
const p = makeProbe(); p.respond(() => { throw new Error('probe unavailable'); });
|
|
522
|
+
const m = new EnvironmentLockManager({ locksDir, now: clk.clock, probe: p.probe, staleAfterMs: 1000 });
|
|
523
|
+
assert.equal((await m.inspectLockState()).state, 'UNKNOWN_STATE', 'probe 失败 → UNKNOWN_STATE');
|
|
524
|
+
await fs.rm(path.join(locksDir, OWNERSHIP_FILE), { force: true });
|
|
525
|
+
await fs.rm(path.join(locksDir, heartbeatFile('unk')), { force: true });
|
|
526
|
+
}
|
|
527
|
+
// OS identity 缺失(capability 或值缺失)且 PID 存活 → UNKNOWN_STATE(保守拒删)
|
|
528
|
+
{
|
|
529
|
+
await seedOwnership(locksDir, { instanceId: 'c', pid: 6666, osIdentity: null });
|
|
530
|
+
await seedHeartbeat(locksDir, 'c', clk.clock() - 2000);
|
|
531
|
+
const p = makeProbe(); p.canOs(false); p.respond(() => ({ alive: true, osProcessStartIdentity: null }));
|
|
532
|
+
const m = new EnvironmentLockManager({ locksDir, now: clk.clock, probe: p.probe, staleAfterMs: 1000 });
|
|
533
|
+
assert.equal((await m.inspectLockState()).state, 'UNKNOWN_STATE', '无法取得 OS identity → UNKNOWN_STATE');
|
|
534
|
+
await fs.rm(path.join(locksDir, OWNERSHIP_FILE), { force: true });
|
|
535
|
+
await fs.rm(path.join(locksDir, heartbeatFile('c')), { force: true });
|
|
536
|
+
}
|
|
537
|
+
// heartbeat 读取失败(EACCES)→ UNKNOWN_STATE
|
|
538
|
+
{
|
|
539
|
+
await seedOwnership(locksDir, { instanceId: 'ac', pid: 7777, osIdentity: 'osA' });
|
|
540
|
+
await seedHeartbeat(locksDir, 'ac', clk.clock());
|
|
541
|
+
const ctl = makeIo();
|
|
542
|
+
const hbAbs = path.join(locksDir, heartbeatFile('ac'));
|
|
543
|
+
ctl.failReadWhen((p) => p === hbAbs);
|
|
544
|
+
const p = makeProbe();
|
|
545
|
+
const m = new EnvironmentLockManager({ locksDir, io: ctl.io, now: clk.clock, probe: p.probe, staleAfterMs: 1000 });
|
|
546
|
+
assert.equal((await m.inspectLockState()).state, 'UNKNOWN_STATE', 'heartbeat 读失败 → UNKNOWN_STATE');
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test('§11.1-c11b issue #36:心跳**长过期** + pid 存活 + 身份不可验证 → 判为可显式回收的残留锁', async (t) => {
|
|
551
|
+
const locksDir = tmp(t);
|
|
552
|
+
// Windows 场景复刻(issue #36 实测):记录侧 osProcessStartIdentity=null、探测侧也拿不到身份
|
|
553
|
+
// (win32 canGetOsIdentity()=false),pid 因复用而"存活",心跳却已停更 9 天。
|
|
554
|
+
const clk = makeClock();
|
|
555
|
+
const nineDaysAgo = clk.clock() - 9 * 86_400_000;
|
|
556
|
+
await seedOwnership(locksDir, { instanceId: 'win36', pid: 24140, osIdentity: null });
|
|
557
|
+
await seedHeartbeat(locksDir, 'win36', nineDaysAgo);
|
|
558
|
+
const p = makeProbe(); p.canOs(false); p.respond(() => ({ alive: true, osProcessStartIdentity: null }));
|
|
559
|
+
|
|
560
|
+
// ① 默认阈值(max(30×staleAfterMs, 30min))下应判 STALE —— 这正是此前永远卡在 UNKNOWN_STATE 的用例
|
|
561
|
+
const m = new EnvironmentLockManager({ locksDir, now: clk.clock, probe: p.probe, staleAfterMs: 10_000 });
|
|
562
|
+
const s = await m.inspectLockState();
|
|
563
|
+
assert.equal(s.state, 'STALE_LOCK_DETECTED', `长过期残留锁必须可识别(此前恒 UNKNOWN_STATE): ${s.detail}`);
|
|
564
|
+
assert.match(s.detail ?? '', /残留锁/, '诊断必须说清「可显式回收」');
|
|
565
|
+
// ② acquire 侧仍然**不自动摘锁**(只分类,不 unlink)
|
|
566
|
+
const res = await m.acquire({ op: 'issue36' });
|
|
567
|
+
assert.equal(res.state, 'STALE_LOCK_DETECTED', 'acquire 只报告,不得自动接管');
|
|
568
|
+
assert.ok(fssync.existsSync(path.join(locksDir, OWNERSHIP_FILE)), 'STALE 判定绝不自动 unlink');
|
|
569
|
+
// ③ 显式回收必须真的成功(此前 recover-stale-lock 拒绝 → 用户只能手工删锁文件)
|
|
570
|
+
const r = await m.recoverStaleLock();
|
|
571
|
+
assert.equal(r.ok, true, `显式回收必须成功: ${r.detail}`);
|
|
572
|
+
assert.equal(r.removed, true);
|
|
573
|
+
assert.equal(fssync.existsSync(path.join(locksDir, OWNERSHIP_FILE)), false, '回收后 ownership 必须消失');
|
|
574
|
+
assert.deepEqual(fssync.readdirSync(locksDir), [], 'heartbeat sidecar 一并清理,无 recovering 残留');
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test('§11.1-c11c issue #36 边界:未达长过期阈值仍保守 UNKNOWN_STATE;heartbeat 缺失也不放宽', async (t) => {
|
|
578
|
+
const locksDir = tmp(t);
|
|
579
|
+
const clk = makeClock();
|
|
580
|
+
const p = makeProbe(); p.canOs(false); p.respond(() => ({ alive: true, osProcessStartIdentity: null }));
|
|
581
|
+
|
|
582
|
+
// ① 只过期 2s(阈值 60s)→ 仍 UNKNOWN_STATE:活着的 owner 只是心跳降级,绝不放宽
|
|
583
|
+
await seedOwnership(locksDir, { instanceId: 'short', pid: 7001, osIdentity: null });
|
|
584
|
+
await seedHeartbeat(locksDir, 'short', clk.clock() - 2_000);
|
|
585
|
+
const m1 = new EnvironmentLockManager({ locksDir, now: clk.clock, probe: p.probe, staleAfterMs: 1000, longExpiredAfterMs: 60_000 });
|
|
586
|
+
assert.equal((await m1.inspectLockState()).state, 'UNKNOWN_STATE', '短过期不得判 stale');
|
|
587
|
+
assert.equal((await m1.recoverStaleLock()).ok, false, '短过期不得被显式回收');
|
|
588
|
+
|
|
589
|
+
// ② heartbeat sidecar 完全缺失(不是"读过且很旧")→ 无从判断过期时长 → 保守 UNKNOWN_STATE
|
|
590
|
+
await seedOwnership(locksDir, { instanceId: 'nohb', pid: 7002, osIdentity: null });
|
|
591
|
+
await fs.rm(path.join(locksDir, heartbeatFile('nohb')), { force: true });
|
|
592
|
+
const m2 = new EnvironmentLockManager({ locksDir, now: clk.clock, probe: p.probe, staleAfterMs: 1000, longExpiredAfterMs: 60_000 });
|
|
593
|
+
assert.equal((await m2.inspectLockState()).state, 'UNKNOWN_STATE', 'sidecar 缺失不得当作"长过期"');
|
|
594
|
+
|
|
595
|
+
// ③ 阈值注入可调:过期 61s > 阈值 60s → STALE(证明阈值真的参与判定,而非硬编码天数)
|
|
596
|
+
// (② 已把 ownership 换成 nohb,这里要连 ownership 一起换回 short)
|
|
597
|
+
await seedOwnership(locksDir, { instanceId: 'short', pid: 7001, osIdentity: null });
|
|
598
|
+
await seedHeartbeat(locksDir, 'short', clk.clock() - 61_000);
|
|
599
|
+
const m3 = new EnvironmentLockManager({ locksDir, now: clk.clock, probe: p.probe, staleAfterMs: 1000, longExpiredAfterMs: 60_000 });
|
|
600
|
+
assert.equal((await m3.inspectLockState()).state, 'STALE_LOCK_DETECTED', '越过阈值即判 stale');
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test('§11.1-c12 definitely stale → acquire 返回 STALE_LOCK_DETECTED(不自动删除)', async (t) => {
|
|
604
|
+
const locksDir = tmp(t);
|
|
605
|
+
await seedOwnership(locksDir, { instanceId: 'dead2', pid: 8888 });
|
|
606
|
+
await seedHeartbeat(locksDir, 'dead2', Date.now() - 20_000);
|
|
607
|
+
const p = makeProbe(); p.respond(() => ({ alive: false, osProcessStartIdentity: null }));
|
|
608
|
+
const m = new EnvironmentLockManager({ locksDir, probe: p.probe, staleAfterMs: 10_000 });
|
|
609
|
+
const res = await m.acquire({ op: 'x' });
|
|
610
|
+
assert.equal(res.state, 'STALE_LOCK_DETECTED');
|
|
611
|
+
assert.ok(fssync.existsSync(path.join(locksDir, OWNERSHIP_FILE)), 'STALE 判定绝不自动 unlink');
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test('§11.1-c13 两 contender 同时发现 stale → 都不自动 destructive takeover(无 unlink)', async (t) => {
|
|
615
|
+
const locksDir = tmp(t);
|
|
616
|
+
await seedOwnership(locksDir, { instanceId: 'dead3', pid: 9999 });
|
|
617
|
+
await seedHeartbeat(locksDir, 'dead3', Date.now() - 20_000);
|
|
618
|
+
const pA = makeProbe(); pA.respond(() => ({ alive: false, osProcessStartIdentity: null }));
|
|
619
|
+
const pB = makeProbe(); pB.respond(() => ({ alive: false, osProcessStartIdentity: null }));
|
|
620
|
+
const a = new EnvironmentLockManager({ locksDir, probe: pA.probe, staleAfterMs: 10_000 });
|
|
621
|
+
const b = new EnvironmentLockManager({ locksDir, probe: pB.probe, staleAfterMs: 10_000 });
|
|
622
|
+
const [ra, rb] = await Promise.all([a.acquire({ op: 'a' }), b.acquire({ op: 'b' })]);
|
|
623
|
+
assert.equal(ra.state, 'STALE_LOCK_DETECTED');
|
|
624
|
+
assert.equal(rb.state, 'STALE_LOCK_DETECTED');
|
|
625
|
+
assert.ok(fssync.existsSync(path.join(locksDir, OWNERSHIP_FILE)), '两 contender 都不得自动删除(无自动 takeover)');
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
test('§11.1-c14 recovery 只删被 rename 捕获且二次验证的 inode;新 owner 不被删', async (t) => {
|
|
629
|
+
const locksDir = tmp(t);
|
|
630
|
+
await seedOwnership(locksDir, { instanceId: 'stale-owner', pid: 10101, osIdentity: 'osS' });
|
|
631
|
+
await seedHeartbeat(locksDir, 'stale-owner', Date.now() - 20_000);
|
|
632
|
+
// capture rename 完成后,立刻出现 successor 新 owner(模拟并发接管)
|
|
633
|
+
const ctl = makeIo();
|
|
634
|
+
const ownershipAbs = path.join(locksDir, OWNERSHIP_FILE);
|
|
635
|
+
ctl.hookRename(async (a, b) => {
|
|
636
|
+
if (a === ownershipAbs) {
|
|
637
|
+
const successor: LockOwnershipRecord = {
|
|
638
|
+
schemaVersion: LOCK_SCHEMA_VERSION,
|
|
639
|
+
owner: { instanceId: 'successor', instanceStartedAt: 2000, pid: process.pid, hostname: os.hostname(), osProcessStartIdentity: 'osNewOwner' },
|
|
640
|
+
op: 'successor-op', target: 'successor', acquiredAt: 3000, lockVersion: '1.0.0', journalId: null,
|
|
641
|
+
};
|
|
642
|
+
await fs.writeFile(a, JSON.stringify(successor));
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
const p = makeProbe(); p.respond(() => ({ alive: false, osProcessStartIdentity: null }));
|
|
646
|
+
const m = new EnvironmentLockManager({ locksDir, io: ctl.io, probe: p.probe, staleAfterMs: 10_000 });
|
|
647
|
+
const r = await m.recoverStaleLock();
|
|
648
|
+
assert.equal(r.ok, true, `recovery 应成功: ${r.detail}`);
|
|
649
|
+
assert.equal(r.removed, true);
|
|
650
|
+
// successor 未被删除(只删被 rename 捕获的 stale inode)
|
|
651
|
+
const successorOwner = (await readOwnership(locksDir)) as LockOwnershipRecord;
|
|
652
|
+
assert.equal(successorOwner.owner.instanceId, 'successor', '后继新 owner 必须保留');
|
|
653
|
+
// 无 recovering 残留
|
|
654
|
+
const files = await fs.readdir(locksDir);
|
|
655
|
+
assert.ok(!files.some((n) => n.startsWith(RECOVERING_PREFIX)), '恢复成功不应残留 recovering 文件');
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
test('§11.1-c16 崩溃模拟(child):持锁后 exit → 残留 lock;recoverStaleLock 显式回收', async (t) => {
|
|
659
|
+
const dir = tmp(t);
|
|
660
|
+
const locksDir = path.join(dir, 'locks');
|
|
661
|
+
// child:用「过去时钟」获取锁(heartbeat 立即写为过期)→ 立即 exit(模拟崩溃,不 release)
|
|
662
|
+
const childScript = childHeader() + `
|
|
663
|
+
const mgr = new EnvironmentLockManager({ locksDir: process.env.LOCKS_DIR, now: () => Date.now() - 20000, heartbeatIntervalMs: 100000 });
|
|
664
|
+
const res = await mgr.acquire({ op: 'crash' });
|
|
665
|
+
if (res.state !== 'ACQUIRED') { console.log('FAIL ' + res.state); process.exit(2); }
|
|
666
|
+
await new Promise(r => setTimeout(r, 300)); // 让首次(过期的)heartbeat 落盘
|
|
667
|
+
console.log('HELD');
|
|
668
|
+
process.exit(0);
|
|
669
|
+
`;
|
|
670
|
+
const file = path.join(dir, 'child16.mjs');
|
|
671
|
+
await fs.writeFile(file, childScript);
|
|
672
|
+
const h = spawnNode(childScript, file, { LOCKS_DIR: locksDir });
|
|
673
|
+
assert.equal(await h.exit, 0);
|
|
674
|
+
assert.ok(h.getStdout().includes('HELD'));
|
|
675
|
+
// 崩溃后残留:environment.lock + heartbeat sidecar
|
|
676
|
+
const files = (await fs.readdir(locksDir)).sort();
|
|
677
|
+
assert.ok(files.includes(OWNERSHIP_FILE), `崩溃后应残留 environment.lock: ${files.join(',')}`);
|
|
678
|
+
assert.ok(files.some((n) => n.startsWith(HEARTBEAT_PREFIX)), '崩溃后应残留 heartbeat sidecar');
|
|
679
|
+
// 父进程探测:child 已退出 → heartbeat 过期 + PID 确证死亡 → STALE
|
|
680
|
+
const insp = await new EnvironmentLockManager({ locksDir, staleAfterMs: 10_000 }).inspectLockState();
|
|
681
|
+
assert.equal(insp.state, 'STALE_LOCK_DETECTED');
|
|
682
|
+
// 显式 recover 成功
|
|
683
|
+
const rec = await new EnvironmentLockManager({ locksDir, staleAfterMs: 10_000 }).recoverStaleLock();
|
|
684
|
+
assert.equal(rec.ok, true);
|
|
685
|
+
assert.equal(rec.removed, true);
|
|
686
|
+
// 回收后可重新 acquire
|
|
687
|
+
const again = await new EnvironmentLockManager({ locksDir, staleAfterMs: 10_000 }).acquire({ op: 'post' });
|
|
688
|
+
assert.equal(again.state, 'ACQUIRED');
|
|
689
|
+
await new EnvironmentLockManager({ locksDir }).release(again.token!);
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
test('§11.1-c17 跨进程互斥集成(child):A 持锁 → B acquire 被拒 → A release → B 成功', async (t) => {
|
|
693
|
+
const dir = tmp(t);
|
|
694
|
+
const locksDir = path.join(dir, 'locks');
|
|
695
|
+
// 子进程 A:acquire → 提示 HELD → 停留 → release → 提示 RELEASED
|
|
696
|
+
const childScript = childHeader() + `
|
|
697
|
+
const mgrA = new EnvironmentLockManager({ locksDir: process.env.LOCKS_DIR, heartbeatIntervalMs: 100000 });
|
|
698
|
+
const res = await mgrA.acquire({ op: 'A' });
|
|
699
|
+
if (res.state !== 'ACQUIRED') { console.log('A-FAIL ' + res.state); process.exit(2); }
|
|
700
|
+
console.log('HELD');
|
|
701
|
+
await new Promise(r => setTimeout(r, 2500));
|
|
702
|
+
await mgrA.release(res.token);
|
|
703
|
+
console.log('RELEASED');
|
|
704
|
+
process.exit(0);
|
|
705
|
+
`;
|
|
706
|
+
const file = path.join(dir, 'child17.mjs');
|
|
707
|
+
await fs.writeFile(file, childScript);
|
|
708
|
+
const h = spawnNode(childScript, file, { LOCKS_DIR: locksDir });
|
|
709
|
+
await waitForSub(() => h.getStdout(), 'HELD');
|
|
710
|
+
// B(父进程同目录)acquire 被拒
|
|
711
|
+
const b = new EnvironmentLockManager({ locksDir });
|
|
712
|
+
const rb = await b.acquire({ op: 'B' });
|
|
713
|
+
assert.notEqual(rb.state, 'ACQUIRED', 'A 持锁期间 B 不得获得锁');
|
|
714
|
+
// 等 A release
|
|
715
|
+
await waitForSub(() => h.getStdout(), 'RELEASED');
|
|
716
|
+
assert.equal(await h.exit, 0);
|
|
717
|
+
// B 再 acquire 成功
|
|
718
|
+
const rb2 = await b.acquire({ op: 'B2' });
|
|
719
|
+
assert.equal(rb2.state, 'ACQUIRED');
|
|
720
|
+
await b.release(rb2.token!);
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
test('§11.1-c18 Windows close→unlink 语义(本机实跑:release 后无句柄占用、可再次 acquire)', async (t) => {
|
|
724
|
+
const locksDir = tmp(t);
|
|
725
|
+
const mgr = new EnvironmentLockManager({ locksDir });
|
|
726
|
+
const r1 = await mgr.acquire({ op: 'win' });
|
|
727
|
+
assert.equal(r1.state, 'ACQUIRED');
|
|
728
|
+
assert.equal(mgr.isHolding, true);
|
|
729
|
+
// 持有期 ownership 存在
|
|
730
|
+
assert.ok(fssync.existsSync(mgr.ownershipPath));
|
|
731
|
+
// release(Windows 必须先 close 再 unlink 才能删被占用文件;env-lock acquire 成功后句柄已 close)
|
|
732
|
+
await mgr.release(r1.token!);
|
|
733
|
+
assert.equal(mgr.isHolding, false);
|
|
734
|
+
assert.equal(fssync.existsSync(mgr.ownershipPath), false, 'release 后 ownership 必须被删除(句柄已释放)');
|
|
735
|
+
// 无句柄占用 → 可再次 acquire
|
|
736
|
+
const r2 = await mgr.acquire({ op: 'win2' });
|
|
737
|
+
assert.equal(r2.state, 'ACQUIRED');
|
|
738
|
+
await mgr.release(r2.token!);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
test('§11.1-c19 同 manager 同 instanceId 两次 acquire:第二次 LOCKED(token 模型,非 reentrant)', async (t) => {
|
|
742
|
+
const locksDir = tmp(t);
|
|
743
|
+
const mgr = new EnvironmentLockManager({ locksDir });
|
|
744
|
+
const r1 = await mgr.acquire({ op: 'import' });
|
|
745
|
+
assert.equal(r1.state, 'ACQUIRED');
|
|
746
|
+
// 同一 manager、同一 instanceId,再次 acquire 必须被拒(operation-scoped,禁止 process-level reentrant)
|
|
747
|
+
await waitHeartbeat(locksDir, r1.token!.instanceId); // 等首写 heartbeat 落盘,确保 EEXIST→inspect 判 fresh→LOCKED(确定性)
|
|
748
|
+
const r2 = await mgr.acquire({ op: 'restore' });
|
|
749
|
+
assert.notEqual(r2.state, 'ACQUIRED', '同 manager 并发 acquire 不得 reentrant 放行');
|
|
750
|
+
assert.equal(r2.state, 'LOCKED', '已有活跃持有(无 parent token)→ LOCKED 被挡');
|
|
751
|
+
assert.equal(mgr.isHolding, true, '首个 token 仍持有');
|
|
752
|
+
await mgr.release(r1.token!);
|
|
753
|
+
assert.equal(mgr.isHolding, false);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
test('§11.1-c20 nested rollback token 传递:withMutationLock 收到有效 parentContext → 复用不 reacquire,release 不释放父 token', async (t) => {
|
|
757
|
+
const locksDir = tmp(t);
|
|
758
|
+
const lock = new EnvironmentLockManager({ locksDir });
|
|
759
|
+
const outer = await withMutationLock(lock, { op: 'import', target: 'executeImportPlan' });
|
|
760
|
+
assert.ok(outer.context !== null, '顶层 import 应获得锁');
|
|
761
|
+
assert.equal(lock.isHolding, true);
|
|
762
|
+
// nested rollback:显式收到 parent token → 校验有效 → reuse,不 reacquire
|
|
763
|
+
const nested = await withMutationLock(lock, { op: 'rollback', target: 'rollback', parentContext: outer.context });
|
|
764
|
+
assert.ok(nested.context !== null);
|
|
765
|
+
assert.equal(nested.context!.token.tokenId, outer.context!.token.tokenId, 'nested 复用父 token(同一 ownership)');
|
|
766
|
+
// nested release = no-op,不得释放父 token
|
|
767
|
+
await nested.release();
|
|
768
|
+
assert.equal(lock.isHolding, true, 'nested.release 不得释放父 token');
|
|
769
|
+
assert.ok(fssync.existsSync(lock.ownershipPath), '父锁仍在磁盘(nested 未释放)');
|
|
770
|
+
// 父 release 才真正释放
|
|
771
|
+
await outer.release();
|
|
772
|
+
assert.equal(lock.isHolding, false);
|
|
773
|
+
assert.equal(fssync.existsSync(lock.ownershipPath), false);
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
test('§11.1-c21 foreign token:manager A 的 token 传给 manager B → validate false;不能绕过 acquire', async (t) => {
|
|
777
|
+
const locksDir = tmp(t);
|
|
778
|
+
const a = new EnvironmentLockManager({ locksDir });
|
|
779
|
+
const b = new EnvironmentLockManager({ locksDir });
|
|
780
|
+
const ra = await a.acquire({ op: 'import' });
|
|
781
|
+
assert.equal(ra.state, 'ACQUIRED');
|
|
782
|
+
const tokenA = ra.token!;
|
|
783
|
+
assert.equal(b.validate(tokenA), false, 'foreign manager 不得 validate 通过');
|
|
784
|
+
// 用 foreign token 作为 b 的 parentContext → 不得绕过 acquire → b 被 a 的锁挡
|
|
785
|
+
const nb = await withMutationLock(b, { op: 'restore', parentContext: { token: tokenA } });
|
|
786
|
+
assert.equal(nb.context, null, 'foreign token 不得授权 b 复用/绕过');
|
|
787
|
+
let fnCalled = false;
|
|
788
|
+
await assert.rejects(
|
|
789
|
+
() => runWithMutationLock(b, { op: 'restore', parentContext: { token: tokenA } }, async () => { fnCalled = true; return 1; }),
|
|
790
|
+
(e) => e instanceof EnvironmentLockUnavailableError,
|
|
791
|
+
);
|
|
792
|
+
assert.equal(fnCalled, false, 'destructive 不得执行');
|
|
793
|
+
await a.release(tokenA);
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
test('§11.1-c22 released token:release 后原 token → validate false,不授权 nested', async (t) => {
|
|
797
|
+
const locksDir = tmp(t);
|
|
798
|
+
const lock = new EnvironmentLockManager({ locksDir });
|
|
799
|
+
const r = await lock.acquire({ op: 'import' });
|
|
800
|
+
const tok = r.token!;
|
|
801
|
+
await lock.release(tok);
|
|
802
|
+
assert.equal(lock.validate(tok), false, '已释放 token 必须失效');
|
|
803
|
+
// 以已释放 token 作 parent → 不授权 reuse → 重新走 acquire(得到新 token)
|
|
804
|
+
const nested = await withMutationLock(lock, { op: 'rollback', parentContext: { token: tok } });
|
|
805
|
+
assert.ok(nested.context !== null);
|
|
806
|
+
assert.notEqual(nested.context!.token.tokenId, tok.tokenId, 'released token 不得复用旧 ownership,应重新 acquire 新 token');
|
|
807
|
+
await nested.release();
|
|
808
|
+
assert.equal(lock.isHolding, false);
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
test('§11.1-c23 同 EnvLockManager 三个并发 acquire → 仅一个 ACQUIRED', async (t) => {
|
|
812
|
+
const locksDir = tmp(t);
|
|
813
|
+
const lock = new EnvironmentLockManager({ locksDir });
|
|
814
|
+
const results = await Promise.all([
|
|
815
|
+
lock.acquire({ op: 'a' }),
|
|
816
|
+
lock.acquire({ op: 'b' }),
|
|
817
|
+
lock.acquire({ op: 'c' }),
|
|
818
|
+
]);
|
|
819
|
+
const acquired = results.filter((r) => r.state === 'ACQUIRED');
|
|
820
|
+
assert.equal(acquired.length, 1, `仅一个进入 mutation,实际 ${results.map((r) => r.state).join(',')}`);
|
|
821
|
+
await lock.release(acquired[0]!.token!);
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
test('§11.1-c24 EPERM/EACCES 且无既有 lock → PERMISSION_ERROR / LOCK_IO_ERROR,非 LOCKED', async (t) => {
|
|
825
|
+
// EPERM:无既有 lock → PERMISSION_ERROR
|
|
826
|
+
{
|
|
827
|
+
const locksDir = tmp(t);
|
|
828
|
+
const ctl = makeIo();
|
|
829
|
+
ctl.failOpenWhen((p) => p.endsWith(OWNERSHIP_FILE), 'EPERM');
|
|
830
|
+
const m = new EnvironmentLockManager({ locksDir, io: ctl.io });
|
|
831
|
+
const res = await m.acquire({ op: 'perm' });
|
|
832
|
+
assert.equal(res.state, 'PERMISSION_ERROR', 'EPERM 无既有锁 → PERMISSION_ERROR,不得误报 LOCKED');
|
|
833
|
+
}
|
|
834
|
+
// EACCES:无既有 lock → PERMISSION_ERROR
|
|
835
|
+
{
|
|
836
|
+
const locksDir = tmp(t);
|
|
837
|
+
const ctl = makeIo();
|
|
838
|
+
ctl.failOpenWhen((p) => p.endsWith(OWNERSHIP_FILE), 'EACCES');
|
|
839
|
+
const m = new EnvironmentLockManager({ locksDir, io: ctl.io });
|
|
840
|
+
const res = await m.acquire({ op: 'acc' });
|
|
841
|
+
assert.equal(res.state, 'PERMISSION_ERROR');
|
|
842
|
+
}
|
|
843
|
+
// 通用 IO 错误(无 EEXIST/EPERM)→ LOCK_IO_ERROR
|
|
844
|
+
{
|
|
845
|
+
const locksDir = tmp(t);
|
|
846
|
+
const ctl = makeIo();
|
|
847
|
+
ctl.failOpenWhen((p) => p.endsWith(OWNERSHIP_FILE)); // 无 code
|
|
848
|
+
const m = new EnvironmentLockManager({ locksDir, io: ctl.io });
|
|
849
|
+
const res = await m.acquire({ op: 'io' });
|
|
850
|
+
assert.equal(res.state, 'LOCK_IO_ERROR');
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
test('§11.1-c25 recovery 二次验证失败 + successor 已存在 → successor 保留、recovering quarantine 不 rename 覆盖', async (t) => {
|
|
855
|
+
const locksDir = tmp(t);
|
|
856
|
+
await seedOwnership(locksDir, { instanceId: 'quar', pid: 12121, osIdentity: 'osQ' });
|
|
857
|
+
await seedHeartbeat(locksDir, 'quar', Date.now() - 20_000);
|
|
858
|
+
// 二次验证失败点:首次 probe(inspect)返回 确证死亡 → 允许 capture;
|
|
859
|
+
// 第二次 probe(reProveStale)返回 alive:true + identity 相同 → reProve 判「非 stale」→ 二次验证失败
|
|
860
|
+
const p = makeProbe();
|
|
861
|
+
let n = 0;
|
|
862
|
+
p.respond(() => {
|
|
863
|
+
n += 1;
|
|
864
|
+
if (n === 1) return { alive: false, osProcessStartIdentity: null };
|
|
865
|
+
return { alive: true, osProcessStartIdentity: 'osQ' }; // 与 recorded 相同 → 二次验证失败
|
|
866
|
+
});
|
|
867
|
+
p.canOs(true);
|
|
868
|
+
// capture rename 后出现 successor(模拟并发接管)
|
|
869
|
+
const ctl = makeIo();
|
|
870
|
+
const ownershipAbs = path.join(locksDir, OWNERSHIP_FILE);
|
|
871
|
+
ctl.hookRename(async (a, b) => {
|
|
872
|
+
if (a === ownershipAbs) {
|
|
873
|
+
const successor: LockOwnershipRecord = {
|
|
874
|
+
schemaVersion: LOCK_SCHEMA_VERSION,
|
|
875
|
+
owner: { instanceId: 'successor2', instanceStartedAt: 2000, pid: process.pid, hostname: os.hostname(), osProcessStartIdentity: 'osNewOwner2' },
|
|
876
|
+
op: 'successor-op', target: 'successor', acquiredAt: 3000, lockVersion: '1.0.0', journalId: null,
|
|
877
|
+
};
|
|
878
|
+
await fs.writeFile(a, JSON.stringify(successor));
|
|
879
|
+
}
|
|
880
|
+
});
|
|
881
|
+
const m = new EnvironmentLockManager({ locksDir, io: ctl.io, probe: p.probe, staleAfterMs: 10_000 });
|
|
882
|
+
const r = await m.recoverStaleLock();
|
|
883
|
+
assert.equal(r.ok, false);
|
|
884
|
+
assert.equal(r.removed, false);
|
|
885
|
+
assert.equal(r.state, 'UNKNOWN_STATE', '二次验证失败 → UNKNOWN_STATE(拒绝删除)');
|
|
886
|
+
// successor 保留不动
|
|
887
|
+
const successorOwner = (await readOwnership(locksDir)) as LockOwnershipRecord;
|
|
888
|
+
assert.equal(successorOwner.owner.instanceId, 'successor2', 'successor 不得被 rename 覆盖/删除');
|
|
889
|
+
// recovering quarantine 文件保留供诊断
|
|
890
|
+
const files = await fs.readdir(locksDir);
|
|
891
|
+
assert.ok(files.some((n) => n.startsWith(RECOVERING_PREFIX)), `应保留 quarantine recovering 文件: ${files.join(',')}`);
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
test('§11-c26 withMutationLock/runWithMutationLock:无 port 直接执行不锁;有 port 被占 → context null(destructive 不执行)', async (t) => {
|
|
895
|
+
// 无 port → 恒成功、不锁定
|
|
896
|
+
{
|
|
897
|
+
const w = await withMutationLock(undefined, { op: 'x' });
|
|
898
|
+
assert.equal(w.context, null);
|
|
899
|
+
await w.release(); // no-op
|
|
900
|
+
let ran = false;
|
|
901
|
+
const v = await runWithMutationLock(undefined, { op: 'y' }, async (ctx) => { ran = true; assert.equal(ctx, null); return 'ok'; });
|
|
902
|
+
assert.equal(v, 'ok');
|
|
903
|
+
assert.equal(ran, true, '无 port 时直接执行');
|
|
904
|
+
}
|
|
905
|
+
// 有 port 且锁被占 → context null(destructive 不执行)
|
|
906
|
+
{
|
|
907
|
+
const locksDir = tmp(t);
|
|
908
|
+
const holder = new EnvironmentLockManager({ locksDir });
|
|
909
|
+
const rh = await holder.acquire({ op: 'hold' });
|
|
910
|
+
const other = new EnvironmentLockManager({ locksDir });
|
|
911
|
+
const w = await withMutationLock(other, { op: 'restore' });
|
|
912
|
+
assert.equal(w.context, null, '锁被占 → context null(blocked)');
|
|
913
|
+
let fnCalled = false;
|
|
914
|
+
await assert.rejects(
|
|
915
|
+
() => runWithMutationLock(other, { op: 'restore' }, async () => { fnCalled = true; return 1; }),
|
|
916
|
+
(e) => e instanceof EnvironmentLockUnavailableError,
|
|
917
|
+
);
|
|
918
|
+
assert.equal(fnCalled, false, 'destructive 不执行');
|
|
919
|
+
await holder.release(rh.token!);
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
/* ------------------------------------------------ L3 回归:release 必须 drain 在途 heartbeat 写 */
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* L3 回归(Windows flake 根因):startHeartbeat 的首次写(及 interval 写)曾是 fire-and-forget。
|
|
927
|
+
* writeHeartbeat 走 atomicWriteFile(tmp 写 → rename,异步多步),若 release 只 stopHeartbeat + unlink sidecar
|
|
928
|
+
* 而不等待在途写,该写会在 unlink **之后**才 rename → 把 sidecar 重新创建(并可能残留 .dshcm.*.tmp),
|
|
929
|
+
* 于是测试 after-hook 的 rmSync(dir) 报 ENOTEMPTY。
|
|
930
|
+
* 本用例用注入 io 把 rename 拖慢(100ms),让 release 必然发生在「写已启动但未落盘」的窗口内。
|
|
931
|
+
*/
|
|
932
|
+
/* ------------------------------------------------ L3 回归:release 必须 drain 在途 heartbeat 写 */
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* L3 回归(Windows flake 根因):startHeartbeat 的首次写(及 interval 写)曾是 fire-and-forget。
|
|
936
|
+
* writeHeartbeat 走 atomicWriteFile(tmp 写 → rename,异步多步);若 release 只 stopHeartbeat + unlink sidecar
|
|
937
|
+
* 而不等待在途写,该写会在 unlink **之后**才 rename → 把 sidecar 重新创建(或残留 .dshcm.*.tmp),
|
|
938
|
+
* 于是测试 after-hook 的 rmSync(dir) 报 ENOTEMPTY。
|
|
939
|
+
*
|
|
940
|
+
* 为了让「release 时确有在途 heartbeat 写」可确定复现(不依赖机器快慢):在 sidecar 路径上先放一个**目录**,
|
|
941
|
+
* 使 atomicWriteFile 的 rename 目标被占用 → renameWithRetry 进入有界退避重试(25/50/100ms,约 175ms),
|
|
942
|
+
* 期间该写必然处于在途状态。修复后 release 会 drain 它(于是 rename 的重试窗口结束、临时文件被清理),
|
|
943
|
+
* 再删除占位目录;未修复时 cleanupHeartbeat 与在途 rename 竞争 → 目录/临时文件残留(after-hook rmSync 失败)。
|
|
944
|
+
*/
|
|
945
|
+
/* ------------------------------------------------ L3 回归:release 必须 drain 在途 heartbeat 写 */
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* L3 回归(Windows flake 根因):startHeartbeat 的首次写(及 interval 写)曾是 fire-and-forget。
|
|
949
|
+
* writeHeartbeat 走 atomicWriteFile(同目录 tmp 写 → rename,异步多步);若 release 只 stopHeartbeat + unlink sidecar
|
|
950
|
+
* 而不等待在途写,该写会在 unlink **之后**才 rename → 把 sidecar 重新创建,或把 .dshcm.*.tmp 留在 locks 目录里,
|
|
951
|
+
* 于是测试 after-hook 的 rmSync(dir) 报 ENOTEMPTY。
|
|
952
|
+
*
|
|
953
|
+
* 为了让「release 时确有在途 heartbeat 写」可确定复现(不依赖机器快慢):在 sidecar 路径上先放一个**目录**,
|
|
954
|
+
* 使 atomicWriteFile 的 rename 目标被占用 → renameWithRetry 进入有界退避重试(25/50/100ms,约 175ms),
|
|
955
|
+
* 期间该写必然处于在途状态(实测:pre-release 目录里可稳定看到 .dshcm.*.tmp)。
|
|
956
|
+
* 修复后 release 会先 stopHeartbeat + 清 activeToken,再 drain 在途写(重试窗口结束、临时文件被清理),最后清 sidecar。
|
|
957
|
+
*/
|
|
958
|
+
test('L3 release 必须 drain 在途 heartbeat 写:不得残留原子写临时文件(after-hook rmSync ENOTEMPTY 根因)', async (t) => {
|
|
959
|
+
const locksDir = tmp(t);
|
|
960
|
+
const mgr = new EnvironmentLockManager({ locksDir, heartbeatIntervalMs: 1 });
|
|
961
|
+
const res = await mgr.acquire({ op: 'hb-drain' });
|
|
962
|
+
assert.equal(res.state, 'ACQUIRED');
|
|
963
|
+
const id = res.token!.instanceId;
|
|
964
|
+
const hbPath = path.join(locksDir, heartbeatFile(id));
|
|
965
|
+
// 让 heartbeat 写链持续有在途写(interval 1ms)
|
|
966
|
+
await sleepReal(60);
|
|
967
|
+
// 占用 sidecar 路径(目录)→ 之后的 heartbeat 写 rename 失败并进入退避重试(在途窗口 ≈175ms)。
|
|
968
|
+
// 写链持续在写,rm 与 mkdir 之间存在竞争,故用有界重试把占位目录稳定建立起来。
|
|
969
|
+
let occupied = false;
|
|
970
|
+
for (let i = 0; i < 50 && !occupied; i++) {
|
|
971
|
+
try {
|
|
972
|
+
await fs.rm(hbPath, { force: true });
|
|
973
|
+
await fs.mkdir(hbPath);
|
|
974
|
+
occupied = true;
|
|
975
|
+
} catch {
|
|
976
|
+
await sleepReal(10);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
assert.equal(occupied, true, '应能把 sidecar 路径占位为目录(用于制造确定性的在途写)');
|
|
980
|
+
await sleepReal(60); // 至少一次写已进入退避重试窗口
|
|
981
|
+
await mgr.release(res.token!);
|
|
982
|
+
// 核心断言:release 返回时在途写必须已 drain —— 不得留下原子写临时文件
|
|
983
|
+
// (未修复时 cleanupHeartbeat 与在途 rename 竞争,.dshcm.*.tmp 会残留 → after-hook rmSync 报 ENOTEMPTY)
|
|
984
|
+
const leftovers = fssync.readdirSync(locksDir).filter((n) => n.startsWith('.dshcm.'));
|
|
985
|
+
assert.deepEqual(leftovers, [], 'release 返回后不得残留原子写临时文件(L3 根因)');
|
|
986
|
+
// 清理占位目录(它本身是被测方无法删除的测试夹具),并确认目录可清空
|
|
987
|
+
try { await fs.rmdir(hbPath); } catch { /* 已被写链清理 */ }
|
|
988
|
+
assert.deepEqual(fssync.readdirSync(locksDir), [], '清理占位目录后 locks 目录必须为空(无 sidecar / 无 .tmp 残留)');
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
test('L3 release 后 locks 目录必须完全为空(模拟 after-hook rmSync 前提)', async (t) => {
|
|
992
|
+
const locksDir = tmp(t);
|
|
993
|
+
const mgr = new EnvironmentLockManager({ locksDir, heartbeatIntervalMs: 50 });
|
|
994
|
+
const res = await mgr.acquire({ op: 'hb-empty' });
|
|
995
|
+
assert.equal(res.state, 'ACQUIRED');
|
|
996
|
+
await mgr.release(res.token!);
|
|
997
|
+
assert.deepEqual(fssync.readdirSync(locksDir), [], 'release 后 locks 目录必须为空(无 sidecar / 无 .tmp 残留)');
|
|
998
|
+
});
|
|
999
|
+
|