@gajae-code/coding-agent 0.12.7 → 0.12.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/dist/types/cli/notify-cli.d.ts +1 -0
- package/dist/types/commands/notify.d.ts +4 -0
- package/dist/types/commands/sdk.d.ts +23 -0
- package/dist/types/config/file-lock.d.ts +3 -0
- package/dist/types/config/model-discovery-manager.d.ts +6 -1
- package/dist/types/config/model-registry.d.ts +2 -0
- package/dist/types/config/resolve-config-value.d.ts +2 -1
- package/dist/types/config/settings-schema.d.ts +17 -1
- package/dist/types/discovery/agents-md.d.ts +11 -1
- package/dist/types/main.d.ts +1 -0
- package/dist/types/modes/components/hook-selector.d.ts +20 -0
- package/dist/types/modes/components/notifications-settings-editor.d.ts +53 -4
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +4 -2
- package/dist/types/modes/types.d.ts +6 -0
- package/dist/types/sdk/broker/broker.d.ts +5 -1
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +1 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
- package/dist/types/sdk/bus/config.d.ts +75 -113
- package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
- package/dist/types/sdk/bus/discord-live-provider.d.ts +8 -2
- package/dist/types/sdk/bus/discord-provider.d.ts +19 -0
- package/dist/types/sdk/bus/index.d.ts +1 -1
- package/dist/types/sdk/bus/notification-orchestration.d.ts +127 -18
- package/dist/types/sdk/bus/notification-service.d.ts +54 -4
- package/dist/types/sdk/bus/session-control.d.ts +8 -3
- package/dist/types/sdk/bus/slack-live-provider.d.ts +9 -2
- package/dist/types/sdk/bus/slack-provider.d.ts +22 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
- package/dist/types/sdk/client/discovery.d.ts +2 -1
- package/dist/types/sdk/host/query/handlers.d.ts +2 -0
- package/dist/types/sdk/index.d.ts +1 -0
- package/dist/types/sdk/providers.d.ts +22 -0
- package/dist/types/sdk/session-directory.d.ts +1 -1
- package/dist/types/session/internal/managed-session-scope.d.ts +17 -1
- package/dist/types/session/internal/managed-session-storage.d.ts +19 -1
- package/dist/types/session/internal/native-publish-outcome.d.ts +3 -2
- package/dist/types/session/internal/session-open-errors.d.ts +5 -0
- package/dist/types/session/session-manager.d.ts +3 -1
- package/dist/types/session/session-storage.d.ts +2 -0
- package/dist/types/system-prompt.d.ts +12 -1
- package/dist/types/tools/composer-bash-policy.d.ts +9 -1
- package/package.json +10 -8
- package/scripts/build-sdk-package-smoke.ts +51 -0
- package/scripts/capture-notifications-settings-showcase.ts +1 -1
- package/scripts/verify-gjc-sdk-canonicalization.ts +14 -1
- package/src/cli/notify-cli.ts +215 -99
- package/src/commands/notify.ts +19 -8
- package/src/commands/sdk.ts +54 -0
- package/src/config/file-lock.ts +6 -0
- package/src/config/model-discovery-manager.ts +50 -8
- package/src/config/model-profiles.ts +8 -0
- package/src/config/model-registry.ts +866 -76
- package/src/config/resolve-config-value.ts +10 -8
- package/src/config/settings-schema.ts +25 -15
- package/src/config/settings.ts +15 -15
- package/src/discovery/agents-md.ts +76 -19
- package/src/gjc-runtime/psmux-detect.ts +12 -16
- package/src/gjc-runtime/session-state-sidecar.ts +12 -2
- package/src/gjc-runtime/tmux-sessions.ts +8 -1
- package/src/internal-urls/docs-index.generated.ts +10 -9
- package/src/main.ts +43 -7
- package/src/modes/DESIGN.md +11 -11
- package/src/modes/components/extensions/inspector-panel.ts +9 -5
- package/src/modes/components/hook-selector.ts +155 -44
- package/src/modes/components/model-selector.ts +242 -49
- package/src/modes/components/notifications-settings-editor.ts +631 -85
- package/src/modes/controllers/extension-ui-controller.ts +114 -15
- package/src/modes/controllers/selector-controller.ts +525 -93
- package/src/modes/print-mode.ts +3 -0
- package/src/modes/types.ts +6 -0
- package/src/prompts/system/system-prompt.md +1 -0
- package/src/sdk/broker/broker.ts +54 -10
- package/src/sdk/broker/lifecycle-ledger.ts +34 -1
- package/src/sdk/broker/lifecycle.ts +47 -50
- package/src/sdk/bus/chat-daemon-cli.ts +12 -5
- package/src/sdk/bus/chat-daemon-control.ts +35 -6
- package/src/sdk/bus/chat-daemon-runtime.ts +16 -2
- package/src/sdk/bus/config.ts +534 -286
- package/src/sdk/bus/daemon-paths.ts +2 -0
- package/src/sdk/bus/discord-live-provider.ts +57 -2
- package/src/sdk/bus/discord-provider.ts +21 -0
- package/src/sdk/bus/index.ts +97 -35
- package/src/sdk/bus/notification-orchestration.ts +371 -68
- package/src/sdk/bus/notification-service.ts +571 -137
- package/src/sdk/bus/session-control.ts +119 -44
- package/src/sdk/bus/slack-live-provider.ts +78 -2
- package/src/sdk/bus/slack-provider.ts +24 -0
- package/src/sdk/bus/telegram-daemon-cli.ts +9 -2
- package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
- package/src/sdk/bus/telegram-daemon-control.ts +3 -3
- package/src/sdk/bus/telegram-daemon.ts +23 -15
- package/src/sdk/client/discovery.ts +10 -4
- package/src/sdk/host/query/handlers.ts +20 -1
- package/src/sdk/index.ts +1 -0
- package/src/sdk/protocol/operation-inventory.generated.json +18 -0
- package/src/sdk/protocol/operation-registry.ts +5 -2
- package/src/sdk/providers.ts +51 -0
- package/src/sdk/session-directory.ts +1 -0
- package/src/sdk/session.ts +34 -14
- package/src/session/agent-session.ts +109 -37
- package/src/session/internal/managed-session-scope.ts +981 -450
- package/src/session/internal/managed-session-storage.ts +418 -47
- package/src/session/internal/native-publish-outcome.ts +15 -3
- package/src/session/internal/session-open-errors.ts +11 -0
- package/src/session/session-manager.ts +27 -3
- package/src/session/session-storage.ts +72 -5
- package/src/slash-commands/builtin-registry.ts +117 -15
- package/src/system-prompt.ts +28 -15
- package/src/task/index.ts +9 -3
- package/src/tools/ask.ts +1 -1
- package/src/tools/bash.ts +4 -2
- package/src/tools/composer-bash-policy.ts +21 -5
- package/src/tools/telegram-send.ts +4 -2
- package/src/utils/command-args.ts +19 -30
- package/src/utils/lang-from-path.ts +2 -4
|
@@ -6,7 +6,10 @@ import {
|
|
|
6
6
|
applyOwnerOnlyFdSecurity,
|
|
7
7
|
applyOwnerOnlyPathSecurity,
|
|
8
8
|
exactRemoveDirectoryTree,
|
|
9
|
+
exactReplacePath,
|
|
10
|
+
exactRestore,
|
|
9
11
|
exactUnlink,
|
|
12
|
+
linkNoReplacePath,
|
|
10
13
|
type NativeDirectoryTreeSnapshot,
|
|
11
14
|
type NativeOwnerOnlySecurityResult,
|
|
12
15
|
openRecoveryFsRoot,
|
|
@@ -34,7 +37,6 @@ const LOCK_LEASE_MS = 60_000;
|
|
|
34
37
|
const LOCK_HEARTBEAT_MS = 10_000;
|
|
35
38
|
const LOCK_WAIT_MS = 5_000;
|
|
36
39
|
|
|
37
|
-
const LOCK_STALE_RECHECK_MS = 100;
|
|
38
40
|
export class ManagedPublishError extends Error {
|
|
39
41
|
readonly classification:
|
|
40
42
|
| "destination_conflict"
|
|
@@ -113,6 +115,21 @@ export function mayCleanManagedTreeStaging(error: unknown): boolean {
|
|
|
113
115
|
return !(error instanceof ManagedTreeMoveOutcomeError) || error.stagingCleanupSafe;
|
|
114
116
|
}
|
|
115
117
|
|
|
118
|
+
/**
|
|
119
|
+
* A filesystem that implements no `renameat2` rename flag at all rejects the no-replace publish
|
|
120
|
+
* before mutating anything: NFS answers `EINVAL` (`invalid_request`) and kernels older than 3.15
|
|
121
|
+
* answer `ENOSYS` (`atomic_unavailable`). Only those two pre-mutation outcomes authorize the
|
|
122
|
+
* `linkat` stand-in; every other failure describes a publish that was actually attempted, and
|
|
123
|
+
* retrying it under a different primitive could publish twice.
|
|
124
|
+
*/
|
|
125
|
+
export function renameFlagsUnsupported(outcome: NativePublishOutcome): boolean {
|
|
126
|
+
return (
|
|
127
|
+
!outcome.ok &&
|
|
128
|
+
mayCleanCurrentStaging(outcome) &&
|
|
129
|
+
(outcome.reason === "atomic_unavailable" || outcome.reason === "invalid_request")
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
116
133
|
export type ManagedSessionSecurityPolicy = "default" | "windows-existing-verify-first";
|
|
117
134
|
|
|
118
135
|
export type ManagedStorageFailure =
|
|
@@ -138,7 +155,38 @@ export interface ManagedStorageLock {
|
|
|
138
155
|
|
|
139
156
|
export interface ManagedFileSnapshot {
|
|
140
157
|
bytes: Buffer;
|
|
141
|
-
identity: {
|
|
158
|
+
identity: {
|
|
159
|
+
dev: bigint;
|
|
160
|
+
ino: bigint;
|
|
161
|
+
nlink: bigint;
|
|
162
|
+
size: number;
|
|
163
|
+
mtimeNs: bigint;
|
|
164
|
+
ctimeNs: bigint;
|
|
165
|
+
sha256: string;
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
type ReplacementCleanupReceipt = {
|
|
170
|
+
version: 1;
|
|
171
|
+
predecessor: string;
|
|
172
|
+
successor: string;
|
|
173
|
+
identity: {
|
|
174
|
+
dev: string;
|
|
175
|
+
ino: string;
|
|
176
|
+
nlink: string;
|
|
177
|
+
size: string;
|
|
178
|
+
mtimeNs: string;
|
|
179
|
+
ctimeNs: string;
|
|
180
|
+
sha256: string;
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const U64_MAX = 18_446_744_073_709_551_615n;
|
|
185
|
+
|
|
186
|
+
function parseCanonicalU64(value: unknown): bigint | undefined {
|
|
187
|
+
if (typeof value !== "string" || !/^(0|[1-9][0-9]*)$/.test(value)) return undefined;
|
|
188
|
+
const parsed = BigInt(value);
|
|
189
|
+
return parsed <= U64_MAX ? parsed : undefined;
|
|
142
190
|
}
|
|
143
191
|
|
|
144
192
|
const ACL_FAILURE_CODES = new Set(["acl_denied", "acl_io_error", "acl_present", "acl_malformed", "acl_unknown"]);
|
|
@@ -264,8 +312,19 @@ type LockRecord = {
|
|
|
264
312
|
createdAt: number;
|
|
265
313
|
heartbeatAt: number;
|
|
266
314
|
leaseExpiresAt: number;
|
|
315
|
+
released?: boolean;
|
|
267
316
|
};
|
|
268
317
|
|
|
318
|
+
export interface ManagedLockRetirementTestEvent {
|
|
319
|
+
readonly path: string;
|
|
320
|
+
readonly attemptId: string;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Test-only seam immediately before exact retirement of one observed lock identity. */
|
|
324
|
+
export const ManagedLockTestHooks: {
|
|
325
|
+
beforeObservedRetirement?: (event: ManagedLockRetirementTestEvent) => void;
|
|
326
|
+
} = {};
|
|
327
|
+
|
|
269
328
|
/** Captured configured-root authority for managed paths only. */
|
|
270
329
|
export interface ManagedDirectoryRoot {
|
|
271
330
|
readonly canonicalPath: string;
|
|
@@ -475,6 +534,7 @@ export class ManagedSessionDescendantStore {
|
|
|
475
534
|
readonly #authority: RecoveryFsRoot | undefined;
|
|
476
535
|
#ownsAuthority = false;
|
|
477
536
|
#closed = false;
|
|
537
|
+
#reconcilingReplacementCleanup = false;
|
|
478
538
|
readonly #authorityBaseDir: string;
|
|
479
539
|
/** Logical profile root inherited by nested managed session destinations. */
|
|
480
540
|
readonly #profileAgentDir: string;
|
|
@@ -650,7 +710,86 @@ export class ManagedSessionDescendantStore {
|
|
|
650
710
|
}
|
|
651
711
|
}
|
|
652
712
|
|
|
713
|
+
#reconcileReplacementCleanupReceipts(): void {
|
|
714
|
+
if (this.#reconcilingReplacementCleanup) return;
|
|
715
|
+
this.#reconcilingReplacementCleanup = true;
|
|
716
|
+
try {
|
|
717
|
+
for (const name of fs.readdirSync(this.#baseDir)) {
|
|
718
|
+
const match = /^\.gjc-replace-cleanup-([0-9a-f]+)-([0-9a-f]+)\.json$/.exec(name);
|
|
719
|
+
if (!match) continue;
|
|
720
|
+
const receiptPath = path.join(this.#baseDir, name);
|
|
721
|
+
const parsed = JSON.parse(
|
|
722
|
+
captureManagedFileNoFollow(receiptPath).bytes.toString("utf8"),
|
|
723
|
+
) as Partial<ReplacementCleanupReceipt>;
|
|
724
|
+
const identity = parsed.identity;
|
|
725
|
+
const dev = parseCanonicalU64(identity?.dev);
|
|
726
|
+
const ino = parseCanonicalU64(identity?.ino);
|
|
727
|
+
const nlink = parseCanonicalU64(identity?.nlink);
|
|
728
|
+
const size = parseCanonicalU64(identity?.size);
|
|
729
|
+
const mtimeNs = parseCanonicalU64(identity?.mtimeNs);
|
|
730
|
+
const ctimeNs = parseCanonicalU64(identity?.ctimeNs);
|
|
731
|
+
const expectedPredecessor =
|
|
732
|
+
dev !== undefined && ino !== undefined
|
|
733
|
+
? path.join(this.#baseDir, `.gjc-exact-replace-destination-${dev.toString(16)}-${ino.toString(16)}`)
|
|
734
|
+
: undefined;
|
|
735
|
+
if (
|
|
736
|
+
parsed.version !== 1 ||
|
|
737
|
+
dev === undefined ||
|
|
738
|
+
ino === undefined ||
|
|
739
|
+
nlink === undefined ||
|
|
740
|
+
size === undefined ||
|
|
741
|
+
mtimeNs === undefined ||
|
|
742
|
+
ctimeNs === undefined ||
|
|
743
|
+
typeof identity?.sha256 !== "string" ||
|
|
744
|
+
!match[1] ||
|
|
745
|
+
!match[2] ||
|
|
746
|
+
BigInt(`0x${match[1]}`) !== dev ||
|
|
747
|
+
BigInt(`0x${match[2]}`) !== ino ||
|
|
748
|
+
typeof parsed.predecessor !== "string" ||
|
|
749
|
+
path.resolve(parsed.predecessor) !== expectedPredecessor ||
|
|
750
|
+
typeof parsed.successor !== "string" ||
|
|
751
|
+
path.dirname(path.resolve(parsed.successor)) !== this.#baseDir
|
|
752
|
+
)
|
|
753
|
+
throw new Error("managed_replace_cleanup_receipt_invalid");
|
|
754
|
+
const retired = exactUnlink(expectedPredecessor, {
|
|
755
|
+
dev,
|
|
756
|
+
ino,
|
|
757
|
+
nlink,
|
|
758
|
+
parentDev: this.#subtreeRoot.dev,
|
|
759
|
+
parentIno: this.#subtreeRoot.ino,
|
|
760
|
+
size,
|
|
761
|
+
mtimeNs,
|
|
762
|
+
sha256: identity.sha256,
|
|
763
|
+
quarantineName: `.gjc-replace-retry-${dev.toString(16)}-${ino.toString(16)}`,
|
|
764
|
+
});
|
|
765
|
+
if (!retired.ok) throw new Error(`managed_replace_cleanup_pending:${retired.code ?? "unknown"}`);
|
|
766
|
+
const receipt = captureManagedFileNoFollow(receiptPath);
|
|
767
|
+
const removed = exactUnlink(receiptPath, {
|
|
768
|
+
dev: receipt.identity.dev,
|
|
769
|
+
ino: receipt.identity.ino,
|
|
770
|
+
nlink: receipt.identity.nlink,
|
|
771
|
+
parentDev: this.#subtreeRoot.dev,
|
|
772
|
+
parentIno: this.#subtreeRoot.ino,
|
|
773
|
+
size: BigInt(receipt.identity.size),
|
|
774
|
+
mtimeNs: receipt.identity.mtimeNs,
|
|
775
|
+
sha256: receipt.identity.sha256,
|
|
776
|
+
quarantineName: `.gjc-receipt-remove-${receipt.identity.dev.toString(16)}-${receipt.identity.ino.toString(16)}`,
|
|
777
|
+
});
|
|
778
|
+
if (!removed.ok) throw new Error(`managed_replace_receipt_cleanup_pending:${removed.code ?? "unknown"}`);
|
|
779
|
+
}
|
|
780
|
+
} finally {
|
|
781
|
+
this.#reconcilingReplacementCleanup = false;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
#beforeMutation(): void {
|
|
786
|
+
this.#assertBound();
|
|
787
|
+
this.#reconcileReplacementCleanupReceipts();
|
|
788
|
+
this.#assertBound();
|
|
789
|
+
}
|
|
790
|
+
|
|
653
791
|
ensureDirectory(relativePath = ""): ManagedDirectoryRoot {
|
|
792
|
+
this.#beforeMutation();
|
|
654
793
|
this.#assertBound();
|
|
655
794
|
if (this.#authority) {
|
|
656
795
|
const relative = this.#relative(this.#resolve(relativePath));
|
|
@@ -670,6 +809,7 @@ export class ManagedSessionDescendantStore {
|
|
|
670
809
|
}
|
|
671
810
|
|
|
672
811
|
async publishNoReplace(relativePath: string, bytes: Uint8Array): Promise<void> {
|
|
812
|
+
this.#beforeMutation();
|
|
673
813
|
const resolved = this.#resolve(relativePath);
|
|
674
814
|
if (this.#authority) {
|
|
675
815
|
this.#assertBound();
|
|
@@ -681,6 +821,7 @@ export class ManagedSessionDescendantStore {
|
|
|
681
821
|
}
|
|
682
822
|
|
|
683
823
|
publishNoReplaceSync(relativePath: string, bytes: Uint8Array): void {
|
|
824
|
+
this.#beforeMutation();
|
|
684
825
|
const resolved = this.#resolve(relativePath);
|
|
685
826
|
if (!this.#authority) {
|
|
686
827
|
publishManagedFileNoReplaceSync(resolved, bytes, this.#root, this.#policy);
|
|
@@ -693,10 +834,17 @@ export class ManagedSessionDescendantStore {
|
|
|
693
834
|
}
|
|
694
835
|
|
|
695
836
|
async replace(relativePath: string, bytes: Uint8Array): Promise<void> {
|
|
837
|
+
this.#beforeMutation();
|
|
696
838
|
this.#assertBound();
|
|
697
839
|
const resolved = this.#resolve(relativePath);
|
|
698
840
|
if (!this.#authority) {
|
|
699
|
-
|
|
841
|
+
try {
|
|
842
|
+
const expected = captureManagedFileNoFollow(resolved);
|
|
843
|
+
replaceManagedFileSync(resolved, bytes, this.#subtreeRoot, this.#policy, undefined, expected.identity);
|
|
844
|
+
} catch (error) {
|
|
845
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
846
|
+
await publishManagedFileNoReplace(resolved, bytes, undefined, this.#subtreeRoot, this.#policy);
|
|
847
|
+
}
|
|
700
848
|
this.#assertBound();
|
|
701
849
|
return;
|
|
702
850
|
}
|
|
@@ -705,11 +853,56 @@ export class ManagedSessionDescendantStore {
|
|
|
705
853
|
this.#assertBound();
|
|
706
854
|
}
|
|
707
855
|
|
|
856
|
+
replaceExpected(relativePath: string, bytes: Uint8Array, expected: ManagedFileSnapshot): void {
|
|
857
|
+
this.#beforeMutation();
|
|
858
|
+
this.#assertBound();
|
|
859
|
+
const resolved = this.#resolve(relativePath);
|
|
860
|
+
if (!this.#authority) {
|
|
861
|
+
const current = captureManagedFileNoFollow(resolved);
|
|
862
|
+
if (!sameIdentity(current.identity, expected.identity) || current.identity.sha256 !== expected.identity.sha256)
|
|
863
|
+
throw new Error("managed_replace_identity_mismatch");
|
|
864
|
+
replaceManagedFileSync(
|
|
865
|
+
resolved,
|
|
866
|
+
bytes,
|
|
867
|
+
this.#subtreeRoot,
|
|
868
|
+
this.#policy,
|
|
869
|
+
() => {
|
|
870
|
+
const currentAtCommit = captureManagedFileNoFollow(resolved);
|
|
871
|
+
if (
|
|
872
|
+
!sameIdentity(currentAtCommit.identity, expected.identity) ||
|
|
873
|
+
currentAtCommit.identity.sha256 !== expected.identity.sha256
|
|
874
|
+
)
|
|
875
|
+
throw new Error("managed_replace_identity_mismatch");
|
|
876
|
+
},
|
|
877
|
+
expected.identity,
|
|
878
|
+
);
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
const replaced = (this.#authority as RecoveryFsRoot & RetainedManagedReplacer).replaceManaged(
|
|
882
|
+
this.#relative(resolved),
|
|
883
|
+
bytes,
|
|
884
|
+
expected.identity.dev.toString(),
|
|
885
|
+
expected.identity.ino.toString(),
|
|
886
|
+
String(expected.identity.size),
|
|
887
|
+
expected.identity.mtimeNs.toString(),
|
|
888
|
+
expected.identity.ctimeNs.toString(),
|
|
889
|
+
expected.identity.sha256,
|
|
890
|
+
);
|
|
891
|
+
if (!replaced.ok) throw new Error(replaced.code ?? "managed_replace_failed");
|
|
892
|
+
this.#assertBound();
|
|
893
|
+
}
|
|
708
894
|
replaceSync(relativePath: string, bytes: Uint8Array): void {
|
|
895
|
+
this.#beforeMutation();
|
|
709
896
|
this.#assertBound();
|
|
710
897
|
const resolved = this.#resolve(relativePath);
|
|
711
898
|
if (!this.#authority) {
|
|
712
|
-
|
|
899
|
+
try {
|
|
900
|
+
const expected = captureManagedFileNoFollow(resolved);
|
|
901
|
+
replaceManagedFileSync(resolved, bytes, this.#subtreeRoot, this.#policy, undefined, expected.identity);
|
|
902
|
+
} catch (error) {
|
|
903
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
904
|
+
publishManagedFileNoReplaceSync(resolved, bytes, this.#subtreeRoot, this.#policy);
|
|
905
|
+
}
|
|
713
906
|
this.#assertBound();
|
|
714
907
|
return;
|
|
715
908
|
}
|
|
@@ -718,6 +911,7 @@ export class ManagedSessionDescendantStore {
|
|
|
718
911
|
}
|
|
719
912
|
|
|
720
913
|
appendSync(relativePath: string, bytes: Uint8Array): void {
|
|
914
|
+
this.#beforeMutation();
|
|
721
915
|
this.#assertBound();
|
|
722
916
|
const resolved = this.#resolve(relativePath);
|
|
723
917
|
let existing = this.readExpected(relativePath);
|
|
@@ -915,6 +1109,7 @@ export class ManagedSessionDescendantStore {
|
|
|
915
1109
|
identity: {
|
|
916
1110
|
dev: BigInt(read.identity.dev),
|
|
917
1111
|
ino: BigInt(read.identity.ino),
|
|
1112
|
+
nlink: BigInt(read.identity.nlink),
|
|
918
1113
|
size: Number(read.identity.size),
|
|
919
1114
|
mtimeNs: BigInt(read.identity.mtimeNs),
|
|
920
1115
|
ctimeNs: BigInt(read.identity.ctimeNs),
|
|
@@ -925,6 +1120,7 @@ export class ManagedSessionDescendantStore {
|
|
|
925
1120
|
|
|
926
1121
|
/** Remove an exact captured file without reopening its pathname as authority. */
|
|
927
1122
|
removeExpected(relativePath: string, expected: ManagedFileSnapshot): void {
|
|
1123
|
+
this.#beforeMutation();
|
|
928
1124
|
this.#assertBound();
|
|
929
1125
|
if (!this.#authority) {
|
|
930
1126
|
const removed = exactUnlink(this.#resolve(relativePath), {
|
|
@@ -964,6 +1160,7 @@ export class ManagedSessionDescendantStore {
|
|
|
964
1160
|
}
|
|
965
1161
|
/** Read and remove one managed descendant through retained authority. */
|
|
966
1162
|
async consume(relativePath: string): Promise<Uint8Array | null> {
|
|
1163
|
+
this.#beforeMutation();
|
|
967
1164
|
this.#assertBound();
|
|
968
1165
|
const resolved = this.#resolve(relativePath);
|
|
969
1166
|
if (this.#authority) {
|
|
@@ -1002,6 +1199,7 @@ export class ManagedSessionDescendantStore {
|
|
|
1002
1199
|
|
|
1003
1200
|
/** Remove one managed descendant through retained authority when it exists. */
|
|
1004
1201
|
async remove(relativePath: string): Promise<void> {
|
|
1202
|
+
this.#beforeMutation();
|
|
1005
1203
|
await this.consume(relativePath);
|
|
1006
1204
|
}
|
|
1007
1205
|
|
|
@@ -1025,6 +1223,7 @@ export class ManagedSessionDescendantStore {
|
|
|
1025
1223
|
destinationRelativePath: string,
|
|
1026
1224
|
snapshot: NativeDirectoryTreeSnapshot,
|
|
1027
1225
|
): Promise<void> {
|
|
1226
|
+
this.#beforeMutation();
|
|
1028
1227
|
const actual = this.captureTree(sourceRelativePath);
|
|
1029
1228
|
if (JSON.stringify(actual) !== JSON.stringify(snapshot)) throw new Error("artifact_source_changed");
|
|
1030
1229
|
this.ensureDirectory(destinationRelativePath);
|
|
@@ -1071,6 +1270,7 @@ export class ManagedSessionDescendantStore {
|
|
|
1071
1270
|
destinationRelativePath: string,
|
|
1072
1271
|
expected: NativeDirectoryTreeSnapshot,
|
|
1073
1272
|
): NativeDirectoryTreeSnapshot {
|
|
1273
|
+
this.#beforeMutation();
|
|
1074
1274
|
this.#assertBound();
|
|
1075
1275
|
const moved = this.#authority
|
|
1076
1276
|
? this.#authority.renameManagedTreeNoReplace(
|
|
@@ -1098,6 +1298,7 @@ export class ManagedSessionDescendantStore {
|
|
|
1098
1298
|
}
|
|
1099
1299
|
|
|
1100
1300
|
removeTreeExpected(relativePath: string, expected: NativeDirectoryTreeSnapshot): void {
|
|
1301
|
+
this.#beforeMutation();
|
|
1101
1302
|
this.#assertBound();
|
|
1102
1303
|
if (!this.#authority) {
|
|
1103
1304
|
const removed = exactRemoveDirectoryTree(this.#resolve(relativePath), expected);
|
|
@@ -1110,6 +1311,7 @@ export class ManagedSessionDescendantStore {
|
|
|
1110
1311
|
this.#assertBound();
|
|
1111
1312
|
}
|
|
1112
1313
|
fsyncTree(): NativeDirectoryTreeSnapshot {
|
|
1314
|
+
this.#beforeMutation();
|
|
1113
1315
|
this.#assertBound();
|
|
1114
1316
|
if (!this.#authority) return fsyncManagedArtifactTree(this.#baseDir);
|
|
1115
1317
|
const baseRelative = this.#relative(this.#baseDir);
|
|
@@ -1205,6 +1407,7 @@ function identity(stat: fs.BigIntStats, sha256 = ""): ManagedFileSnapshot["ident
|
|
|
1205
1407
|
return {
|
|
1206
1408
|
dev: stat.dev,
|
|
1207
1409
|
ino: stat.ino,
|
|
1410
|
+
nlink: stat.nlink,
|
|
1208
1411
|
size: Number(stat.size),
|
|
1209
1412
|
mtimeNs: stat.mtimeNs,
|
|
1210
1413
|
ctimeNs: stat.ctimeNs,
|
|
@@ -1216,6 +1419,7 @@ function sameIdentity(left: ManagedFileSnapshot["identity"], right: ManagedFileS
|
|
|
1216
1419
|
return (
|
|
1217
1420
|
left.dev === right.dev &&
|
|
1218
1421
|
left.ino === right.ino &&
|
|
1422
|
+
left.nlink === right.nlink &&
|
|
1219
1423
|
left.size === right.size &&
|
|
1220
1424
|
left.mtimeNs === right.mtimeNs &&
|
|
1221
1425
|
left.ctimeNs === right.ctimeNs
|
|
@@ -1240,11 +1444,9 @@ function isCtimeOnlyIdentityMismatch(
|
|
|
1240
1444
|
return sameStableIdentityIgnoringCtime(observed, expected) && observed.ctimeNs !== expected.ctimeNs;
|
|
1241
1445
|
}
|
|
1242
1446
|
|
|
1243
|
-
function
|
|
1447
|
+
function parseLockBytes(bytes: Uint8Array): LockRecord | undefined {
|
|
1244
1448
|
try {
|
|
1245
|
-
const
|
|
1246
|
-
if (!stat.isFile() || stat.isSymbolicLink()) return undefined;
|
|
1247
|
-
const value: unknown = JSON.parse(fs.readFileSync(pathname, "utf8"));
|
|
1449
|
+
const value: unknown = JSON.parse(Buffer.from(bytes).toString("utf8"));
|
|
1248
1450
|
if (!value || typeof value !== "object") return undefined;
|
|
1249
1451
|
const record = value as Partial<LockRecord>;
|
|
1250
1452
|
return typeof record.attemptId === "string" &&
|
|
@@ -1252,7 +1454,8 @@ function parseLock(pathname: string): LockRecord | undefined {
|
|
|
1252
1454
|
typeof record.processStartId === "string" &&
|
|
1253
1455
|
typeof record.leaseExpiresAt === "number" &&
|
|
1254
1456
|
typeof record.heartbeatAt === "number" &&
|
|
1255
|
-
typeof record.createdAt === "number"
|
|
1457
|
+
typeof record.createdAt === "number" &&
|
|
1458
|
+
(record.released === undefined || typeof record.released === "boolean")
|
|
1256
1459
|
? (record as LockRecord)
|
|
1257
1460
|
: undefined;
|
|
1258
1461
|
} catch {
|
|
@@ -1260,6 +1463,26 @@ function parseLock(pathname: string): LockRecord | undefined {
|
|
|
1260
1463
|
}
|
|
1261
1464
|
}
|
|
1262
1465
|
|
|
1466
|
+
function parseLock(pathname: string): LockRecord | undefined {
|
|
1467
|
+
try {
|
|
1468
|
+
const stat = fs.lstatSync(pathname);
|
|
1469
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return undefined;
|
|
1470
|
+
return parseLockBytes(fs.readFileSync(pathname));
|
|
1471
|
+
} catch {
|
|
1472
|
+
return undefined;
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
function captureLock(pathname: string): { record: LockRecord; snapshot: ManagedFileSnapshot } | undefined {
|
|
1477
|
+
try {
|
|
1478
|
+
const snapshot = captureManagedFileNoFollow(pathname);
|
|
1479
|
+
const record = parseLockBytes(snapshot.bytes);
|
|
1480
|
+
return record ? { record, snapshot } : undefined;
|
|
1481
|
+
} catch {
|
|
1482
|
+
return undefined;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1263
1486
|
function ownerDefinitelyGone(record: LockRecord): boolean {
|
|
1264
1487
|
if (record.bootId && bootId() && record.bootId !== bootId()) return true;
|
|
1265
1488
|
try {
|
|
@@ -1373,6 +1596,7 @@ export async function publishManagedFileNoReplace(
|
|
|
1373
1596
|
let failure: unknown;
|
|
1374
1597
|
let outcome: NativePublishOutcome | undefined;
|
|
1375
1598
|
let renameAttempted = false;
|
|
1599
|
+
let linkPublished = false;
|
|
1376
1600
|
|
|
1377
1601
|
try {
|
|
1378
1602
|
assertOwned?.();
|
|
@@ -1392,6 +1616,16 @@ export async function publishManagedFileNoReplace(
|
|
|
1392
1616
|
|
|
1393
1617
|
renameAttempted = true;
|
|
1394
1618
|
outcome = classifyNativePublishOutcome(renameNoReplacePath(staging, destination));
|
|
1619
|
+
if (renameFlagsUnsupported(outcome)) {
|
|
1620
|
+
// linkat publishes the destination without consuming the staging name, so the
|
|
1621
|
+
// secured staging descriptor stays authoritative across publication exactly as
|
|
1622
|
+
// it does across a rename. The staging link is removed in the finally block
|
|
1623
|
+
// below, after that descriptor is closed: unlinking a still-open name on NFS
|
|
1624
|
+
// silly-renames it instead of removing it, which would leave a second link on
|
|
1625
|
+
// the published inode.
|
|
1626
|
+
outcome = classifyNativePublishOutcome(linkNoReplacePath(staging, destination));
|
|
1627
|
+
linkPublished = outcome.ok;
|
|
1628
|
+
}
|
|
1395
1629
|
|
|
1396
1630
|
if (!outcome.ok) throw publishFailure(outcome);
|
|
1397
1631
|
|
|
@@ -1414,7 +1648,7 @@ export async function publishManagedFileNoReplace(
|
|
|
1414
1648
|
} finally {
|
|
1415
1649
|
if (fd !== undefined) fs.closeSync(fd);
|
|
1416
1650
|
|
|
1417
|
-
if (stagingIdentity && (!renameAttempted || (outcome && mayCleanCurrentStaging(outcome)))) {
|
|
1651
|
+
if (stagingIdentity && (linkPublished || !renameAttempted || (outcome && mayCleanCurrentStaging(outcome)))) {
|
|
1418
1652
|
await fsp
|
|
1419
1653
|
.lstat(staging, { bigint: true })
|
|
1420
1654
|
.then(stat => {
|
|
@@ -1442,6 +1676,7 @@ export function publishManagedFileNoReplaceSync(
|
|
|
1442
1676
|
let stagingIdentity: { dev: bigint; ino: bigint } | undefined;
|
|
1443
1677
|
let failure: unknown;
|
|
1444
1678
|
let outcome: NativePublishOutcome | undefined;
|
|
1679
|
+
let linkPublished = false;
|
|
1445
1680
|
|
|
1446
1681
|
try {
|
|
1447
1682
|
fd = fs.openSync(
|
|
@@ -1458,6 +1693,12 @@ export function publishManagedFileNoReplaceSync(
|
|
|
1458
1693
|
stagingIdentity = { dev: staged.dev, ino: staged.ino };
|
|
1459
1694
|
|
|
1460
1695
|
outcome = classifyNativePublishOutcome(renameNoReplacePath(staging, destination));
|
|
1696
|
+
if (renameFlagsUnsupported(outcome)) {
|
|
1697
|
+
// See publishManagedFileNoReplace: the staging link outlives this publication
|
|
1698
|
+
// and is removed only after the secured descriptor is closed.
|
|
1699
|
+
outcome = classifyNativePublishOutcome(linkNoReplacePath(staging, destination));
|
|
1700
|
+
linkPublished = outcome.ok;
|
|
1701
|
+
}
|
|
1461
1702
|
if (!outcome.ok) throw publishFailure(outcome);
|
|
1462
1703
|
|
|
1463
1704
|
const named = fs.lstatSync(destination, { bigint: true });
|
|
@@ -1473,7 +1714,7 @@ export function publishManagedFileNoReplaceSync(
|
|
|
1473
1714
|
} finally {
|
|
1474
1715
|
if (fd !== undefined) fs.closeSync(fd);
|
|
1475
1716
|
}
|
|
1476
|
-
if (stagingIdentity && outcome && mayCleanCurrentStaging(outcome)) {
|
|
1717
|
+
if (stagingIdentity && (linkPublished || (outcome && mayCleanCurrentStaging(outcome)))) {
|
|
1477
1718
|
try {
|
|
1478
1719
|
const named = fs.lstatSync(staging, { bigint: true });
|
|
1479
1720
|
if (named.dev !== stagingIdentity.dev || named.ino !== stagingIdentity.ino) {
|
|
@@ -1493,12 +1734,15 @@ export function replaceManagedFileSync(
|
|
|
1493
1734
|
bytes: Uint8Array,
|
|
1494
1735
|
root: ManagedDirectoryRoot,
|
|
1495
1736
|
policy: ManagedSessionSecurityPolicy = "default",
|
|
1737
|
+
assertFence?: () => void,
|
|
1738
|
+
expectedDestination?: ManagedFileSnapshot["identity"],
|
|
1496
1739
|
): void {
|
|
1497
1740
|
const parent = path.dirname(destination);
|
|
1498
1741
|
ensureManagedDirectory(parent, root, policy);
|
|
1499
1742
|
const staging = path.join(parent, `.${path.basename(destination)}.${randomUUID()}.replacement`);
|
|
1500
1743
|
let fd: number | undefined;
|
|
1501
1744
|
let stagedIdentity: { dev: bigint; ino: bigint } | undefined;
|
|
1745
|
+
let preserveStaging = false;
|
|
1502
1746
|
try {
|
|
1503
1747
|
fd = fs.openSync(
|
|
1504
1748
|
staging,
|
|
@@ -1512,20 +1756,137 @@ export function replaceManagedFileSync(
|
|
|
1512
1756
|
secureFileDescriptor(staging, fd, "verify");
|
|
1513
1757
|
const staged = fs.fstatSync(fd, { bigint: true });
|
|
1514
1758
|
stagedIdentity = { dev: staged.dev, ino: staged.ino };
|
|
1759
|
+
if (process.platform === "win32" && expectedDestination) {
|
|
1760
|
+
fs.closeSync(fd);
|
|
1761
|
+
fd = undefined;
|
|
1762
|
+
}
|
|
1515
1763
|
assertManagedDirectoryRoot(root);
|
|
1516
|
-
|
|
1764
|
+
assertFence?.();
|
|
1765
|
+
if (process.platform !== "win32" && expectedDestination) {
|
|
1766
|
+
throw new Error("managed_replace_exact_unavailable");
|
|
1767
|
+
}
|
|
1768
|
+
if (process.platform === "win32" && expectedDestination) {
|
|
1769
|
+
const parentIdentity = fs.lstatSync(parent, { bigint: true });
|
|
1770
|
+
const replaced = exactReplacePath(
|
|
1771
|
+
staging,
|
|
1772
|
+
destination,
|
|
1773
|
+
{
|
|
1774
|
+
dev: staged.dev,
|
|
1775
|
+
ino: staged.ino,
|
|
1776
|
+
nlink: staged.nlink,
|
|
1777
|
+
parentDev: parentIdentity.dev,
|
|
1778
|
+
parentIno: parentIdentity.ino,
|
|
1779
|
+
size: staged.size,
|
|
1780
|
+
mtimeNs: staged.mtimeNs,
|
|
1781
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
1782
|
+
},
|
|
1783
|
+
{
|
|
1784
|
+
dev: expectedDestination.dev,
|
|
1785
|
+
ino: expectedDestination.ino,
|
|
1786
|
+
nlink: expectedDestination.nlink,
|
|
1787
|
+
parentDev: parentIdentity.dev,
|
|
1788
|
+
parentIno: parentIdentity.ino,
|
|
1789
|
+
size: BigInt(expectedDestination.size),
|
|
1790
|
+
mtimeNs: expectedDestination.mtimeNs,
|
|
1791
|
+
sha256: expectedDestination.sha256,
|
|
1792
|
+
},
|
|
1793
|
+
);
|
|
1794
|
+
if (!replaced.ok) {
|
|
1795
|
+
const committedWithPredecessor =
|
|
1796
|
+
replaced.detachedPath === replaced.retainedPlaceholderPath &&
|
|
1797
|
+
replaced.retainedSuccessorPath === destination;
|
|
1798
|
+
if (committedWithPredecessor) {
|
|
1799
|
+
const retired = exactUnlink(replaced.retainedPlaceholderPath!, {
|
|
1800
|
+
dev: expectedDestination.dev,
|
|
1801
|
+
ino: expectedDestination.ino,
|
|
1802
|
+
nlink: expectedDestination.nlink,
|
|
1803
|
+
parentDev: parentIdentity.dev,
|
|
1804
|
+
parentIno: parentIdentity.ino,
|
|
1805
|
+
size: BigInt(expectedDestination.size),
|
|
1806
|
+
mtimeNs: expectedDestination.mtimeNs,
|
|
1807
|
+
sha256: expectedDestination.sha256,
|
|
1808
|
+
quarantineName: `.gjc-replace-cleanup-${expectedDestination.dev.toString(16)}-${expectedDestination.ino.toString(16)}`,
|
|
1809
|
+
});
|
|
1810
|
+
if (!retired.ok) {
|
|
1811
|
+
const predecessor =
|
|
1812
|
+
retired.detachedPath ?? retired.retainedPlaceholderPath ?? replaced.retainedPlaceholderPath;
|
|
1813
|
+
const receiptPath = path.join(
|
|
1814
|
+
parent,
|
|
1815
|
+
`.gjc-replace-cleanup-${expectedDestination.dev.toString(16)}-${expectedDestination.ino.toString(16)}.json`,
|
|
1816
|
+
);
|
|
1817
|
+
publishManagedFileNoReplaceSync(
|
|
1818
|
+
receiptPath,
|
|
1819
|
+
Buffer.from(
|
|
1820
|
+
JSON.stringify({
|
|
1821
|
+
version: 1,
|
|
1822
|
+
predecessor,
|
|
1823
|
+
successor: destination,
|
|
1824
|
+
identity: {
|
|
1825
|
+
dev: expectedDestination.dev.toString(),
|
|
1826
|
+
ino: expectedDestination.ino.toString(),
|
|
1827
|
+
nlink: expectedDestination.nlink.toString(),
|
|
1828
|
+
size: String(expectedDestination.size),
|
|
1829
|
+
mtimeNs: expectedDestination.mtimeNs.toString(),
|
|
1830
|
+
ctimeNs: expectedDestination.ctimeNs.toString(),
|
|
1831
|
+
sha256: expectedDestination.sha256,
|
|
1832
|
+
},
|
|
1833
|
+
}),
|
|
1834
|
+
),
|
|
1835
|
+
root,
|
|
1836
|
+
policy,
|
|
1837
|
+
);
|
|
1838
|
+
throw new Error(
|
|
1839
|
+
`managed_replace_cleanup_pending:receipt=${receiptPath}:predecessor=${predecessor}:successor=${destination}:code=${retired.code ?? "unknown"}`,
|
|
1840
|
+
);
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
if (!committedWithPredecessor) {
|
|
1844
|
+
if (replaced.detachedPath) {
|
|
1845
|
+
const restored = exactRestore(replaced.detachedPath, staging, {
|
|
1846
|
+
dev: staged.dev,
|
|
1847
|
+
ino: staged.ino,
|
|
1848
|
+
nlink: staged.nlink,
|
|
1849
|
+
parentDev: parentIdentity.dev,
|
|
1850
|
+
parentIno: parentIdentity.ino,
|
|
1851
|
+
size: staged.size,
|
|
1852
|
+
mtimeNs: staged.mtimeNs,
|
|
1853
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
1854
|
+
});
|
|
1855
|
+
if (restored.ok) preserveStaging = true;
|
|
1856
|
+
if (!restored.ok)
|
|
1857
|
+
throw new Error(
|
|
1858
|
+
`managed_replace_failed:${replaced.code ?? "unknown"}:retained=${replaced.detachedPath}:restore=${restored.code ?? "unknown"}`,
|
|
1859
|
+
);
|
|
1860
|
+
}
|
|
1861
|
+
const retained = [
|
|
1862
|
+
replaced.detachedPath,
|
|
1863
|
+
replaced.retainedSuccessorPath,
|
|
1864
|
+
replaced.retainedPlaceholderPath,
|
|
1865
|
+
replaced.retainedUnknownPath,
|
|
1866
|
+
]
|
|
1867
|
+
.filter((pathname): pathname is string => typeof pathname === "string")
|
|
1868
|
+
.join(",");
|
|
1869
|
+
throw new Error(
|
|
1870
|
+
`managed_replace_failed:${replaced.code ?? "unknown"}${retained ? `:retained=${retained}` : ""}`,
|
|
1871
|
+
);
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
} else fs.renameSync(staging, destination);
|
|
1875
|
+
assertFence?.();
|
|
1517
1876
|
assertManagedDirectoryRoot(root);
|
|
1518
1877
|
const named = fs.lstatSync(destination, { bigint: true });
|
|
1519
1878
|
if (!named.isFile() || named.isSymbolicLink() || named.dev !== staged.dev || named.ino !== staged.ino) {
|
|
1520
1879
|
throw new Error("destination_identity_changed");
|
|
1521
1880
|
}
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1881
|
+
if (fd !== undefined) {
|
|
1882
|
+
secureFileDescriptor(destination, fd, "verify");
|
|
1883
|
+
fs.closeSync(fd);
|
|
1884
|
+
fd = undefined;
|
|
1885
|
+
}
|
|
1525
1886
|
fsyncDirectory(parent);
|
|
1526
1887
|
} finally {
|
|
1527
1888
|
if (fd !== undefined) fs.closeSync(fd);
|
|
1528
|
-
if (stagedIdentity) {
|
|
1889
|
+
if (stagedIdentity && !preserveStaging) {
|
|
1529
1890
|
try {
|
|
1530
1891
|
const named = fs.lstatSync(staging, { bigint: true });
|
|
1531
1892
|
if (named.dev === stagedIdentity.dev && named.ino === stagedIdentity.ino) fs.unlinkSync(staging);
|
|
@@ -1571,7 +1932,6 @@ export async function acquireManagedLock(
|
|
|
1571
1932
|
ensureManagedDirectory(locksDirectory, root, policy);
|
|
1572
1933
|
const lockPath = path.join(locksDirectory, `${name}.lock`);
|
|
1573
1934
|
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
1574
|
-
let staleObservedAt: number | undefined;
|
|
1575
1935
|
while (true) {
|
|
1576
1936
|
const attemptId = randomUUID();
|
|
1577
1937
|
const now = Date.now();
|
|
@@ -1604,12 +1964,8 @@ export async function acquireManagedLock(
|
|
|
1604
1964
|
let descriptorClosed = false;
|
|
1605
1965
|
const closeDescriptor = (): void => {
|
|
1606
1966
|
if (descriptorClosed) return;
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
} finally {
|
|
1610
|
-
fs.closeSync(fd);
|
|
1611
|
-
descriptorClosed = true;
|
|
1612
|
-
}
|
|
1967
|
+
fs.closeSync(fd);
|
|
1968
|
+
descriptorClosed = true;
|
|
1613
1969
|
};
|
|
1614
1970
|
const assertOwned = (): void => {
|
|
1615
1971
|
const current = parseLock(lockPath);
|
|
@@ -1623,17 +1979,23 @@ export async function acquireManagedLock(
|
|
|
1623
1979
|
released ||
|
|
1624
1980
|
descriptorClosed ||
|
|
1625
1981
|
!current ||
|
|
1982
|
+
current.released === true ||
|
|
1626
1983
|
!sameFileIdentity(lockIdentity, named) ||
|
|
1627
|
-
current.attemptId !== attemptId
|
|
1628
|
-
current.leaseExpiresAt < Date.now()
|
|
1984
|
+
current.attemptId !== attemptId
|
|
1629
1985
|
)
|
|
1630
1986
|
throw new Error("migration_busy");
|
|
1987
|
+
const now = Date.now();
|
|
1988
|
+
if (current.leaseExpiresAt < now + LOCK_HEARTBEAT_MS) {
|
|
1989
|
+
writeLockDescriptor(fd, {
|
|
1990
|
+
...record,
|
|
1991
|
+
heartbeatAt: now,
|
|
1992
|
+
leaseExpiresAt: now + LOCK_LEASE_MS,
|
|
1993
|
+
});
|
|
1994
|
+
}
|
|
1631
1995
|
};
|
|
1632
1996
|
const heartbeat = setInterval(() => {
|
|
1633
1997
|
try {
|
|
1634
1998
|
assertOwned();
|
|
1635
|
-
const now = Date.now();
|
|
1636
|
-
writeLockDescriptor(fd, { ...record, heartbeatAt: now, leaseExpiresAt: now + LOCK_LEASE_MS });
|
|
1637
1999
|
} catch {
|
|
1638
2000
|
/* fencing rejects later publication */
|
|
1639
2001
|
}
|
|
@@ -1646,10 +2008,16 @@ export async function acquireManagedLock(
|
|
|
1646
2008
|
clearInterval(heartbeat);
|
|
1647
2009
|
try {
|
|
1648
2010
|
assertOwned();
|
|
2011
|
+
secureFileDescriptor(lockPath, fd, "verify");
|
|
1649
2012
|
const now = Date.now();
|
|
1650
|
-
//
|
|
1651
|
-
//
|
|
1652
|
-
writeLockDescriptor(fd, {
|
|
2013
|
+
// A released record is the only live-process reclaim authority. Expiry alone
|
|
2014
|
+
// never authorizes stealing from a holder whose process is still present.
|
|
2015
|
+
writeLockDescriptor(fd, {
|
|
2016
|
+
...record,
|
|
2017
|
+
released: true,
|
|
2018
|
+
heartbeatAt: now,
|
|
2019
|
+
leaseExpiresAt: now,
|
|
2020
|
+
});
|
|
1653
2021
|
fsyncDirectory(locksDirectory);
|
|
1654
2022
|
} finally {
|
|
1655
2023
|
released = true;
|
|
@@ -1659,24 +2027,27 @@ export async function acquireManagedLock(
|
|
|
1659
2027
|
};
|
|
1660
2028
|
} catch (error) {
|
|
1661
2029
|
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
1662
|
-
const
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
2030
|
+
const observed = captureLock(lockPath);
|
|
2031
|
+
const owner = observed?.record;
|
|
2032
|
+
const reclaimable =
|
|
2033
|
+
owner?.released === true ||
|
|
2034
|
+
(owner !== undefined && owner.leaseExpiresAt < Date.now() && ownerDefinitelyGone(owner));
|
|
2035
|
+
if (observed && owner && reclaimable) {
|
|
2036
|
+
try {
|
|
2037
|
+
ManagedLockTestHooks.beforeObservedRetirement?.({ path: lockPath, attemptId: owner.attemptId });
|
|
2038
|
+
const removed = exactUnlink(lockPath, {
|
|
2039
|
+
dev: observed.snapshot.identity.dev,
|
|
2040
|
+
ino: observed.snapshot.identity.ino,
|
|
2041
|
+
size: BigInt(observed.snapshot.identity.size),
|
|
2042
|
+
mtimeNs: observed.snapshot.identity.mtimeNs,
|
|
2043
|
+
sha256: observed.snapshot.identity.sha256,
|
|
2044
|
+
quarantineName: `.gjc-lock-${randomUUID()}.stale`,
|
|
2045
|
+
});
|
|
2046
|
+
if (removed.ok || removed.code === "cleanup_pending") fsyncDirectory(locksDirectory);
|
|
2047
|
+
} catch {
|
|
2048
|
+
/* retry owner observation */
|
|
1678
2049
|
}
|
|
1679
|
-
}
|
|
2050
|
+
}
|
|
1680
2051
|
if (Date.now() >= deadline) throw new Error("migration_busy");
|
|
1681
2052
|
await new Promise<void>(resolve => setTimeout(resolve, 50));
|
|
1682
2053
|
}
|