orizu 0.5.50 → 0.5.51
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/dist/auth-commands.d.ts.map +1 -1
- package/dist/auth-commands.js +10 -6
- package/dist/credentials-transaction.d.ts +7 -0
- package/dist/credentials-transaction.d.ts.map +1 -0
- package/dist/credentials-transaction.js +60 -0
- package/dist/credentials.d.ts +19 -1
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +118 -73
- package/dist/stored-session-requests.d.ts.map +1 -1
- package/dist/stored-session-requests.js +8 -4
- package/package.json +1 -1
- package/src/auth-commands.ts +11 -6
- package/src/credentials-transaction.ts +73 -0
- package/src/credentials.ts +128 -86
- package/src/stored-session-requests.ts +7 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth-commands.d.ts","sourceRoot":"","sources":["../src/auth-commands.ts"],"names":[],"mappings":"AA6BA,iBAAS,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAAC,cAAc,CAEnE;AAMD,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAE5D;AAQD,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,EAAE,cAAc,CAAC,EAAE,MAAM,GAAG,GAAG,CAwB5E;AA6BD,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAgBtD;AAED,iBAAS,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEzC;
|
|
1
|
+
{"version":3,"file":"auth-commands.d.ts","sourceRoot":"","sources":["../src/auth-commands.ts"],"names":[],"mappings":"AA6BA,iBAAS,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAAC,cAAc,CAEnE;AAMD,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAE5D;AAQD,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,EAAE,cAAc,CAAC,EAAE,MAAM,GAAG,GAAG,CAwB5E;AA6BD,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAgBtD;AAED,iBAAS,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEzC;AA4BD,iBAAe,KAAK,CAAC,eAAe,CAAC,EAAE,MAAM,iBAoC5C;AAoHD,iBAAe,MAAM,kBAWpB;AAED,iBAAe,MAAM,CAAC,eAAe,CAAC,EAAE,MAAM,iBAwC7C;AAED,iBAAe,QAAQ,kBA0BtB;AAED,OAAO,EAAE,WAAW,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAA"}
|
package/dist/auth-commands.js
CHANGED
|
@@ -101,6 +101,9 @@ function resolveAuthCallbackPort() {
|
|
|
101
101
|
}
|
|
102
102
|
return parsed;
|
|
103
103
|
}
|
|
104
|
+
function isCredentialsFileFailure(message) {
|
|
105
|
+
return message.startsWith('ORIZU_CREDENTIALS_READ_FAILED:') || message.startsWith('ORIZU_CREDENTIALS_WRITE_FAILED:');
|
|
106
|
+
}
|
|
104
107
|
async function login(baseUrlOverride) {
|
|
105
108
|
const target = baseUrlOverride !== undefined
|
|
106
109
|
? { baseUrl: baseUrlOverride, isDefault: false }
|
|
@@ -113,14 +116,15 @@ async function login(baseUrlOverride) {
|
|
|
113
116
|
loginData = await authenticateAtBaseUrl(baseUrl);
|
|
114
117
|
if (!loginData)
|
|
115
118
|
return;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
});
|
|
119
|
+
const apiKey = loginData.apiKey;
|
|
120
|
+
// Waits for the lock without freezing the CLI (ORI-2421).
|
|
121
|
+
const { withCredentialsTransaction } = await import('./credentials-transaction.js');
|
|
122
|
+
await withCredentialsTransaction(async () => saveServerCredentials(baseUrl, { credentialType: 'pat', apiKey }));
|
|
120
123
|
}
|
|
121
124
|
catch (error) {
|
|
122
|
-
|
|
123
|
-
|
|
125
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
126
|
+
// Reading or saving credentials.json is a disk problem, not a connection one.
|
|
127
|
+
if (target.isDefault && !isCredentialsFileFailure(detail)) {
|
|
124
128
|
throw new Error(`${detail} Check connectivity to https://app.orizu.ai, then retry \`orizu login\`.`, { cause: error });
|
|
125
129
|
}
|
|
126
130
|
throw error;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A credentials transaction whose wait for the lock stays responsive. The
|
|
3
|
+
* in-process rules are credentials.ts's: one holder per process, and nested
|
|
4
|
+
* writes only from the holder's own async context.
|
|
5
|
+
*/
|
|
6
|
+
export declare function withCredentialsTransaction<T>(operation: () => Promise<T>, signal?: AbortSignal | null): Promise<T>;
|
|
7
|
+
//# sourceMappingURL=credentials-transaction.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"credentials-transaction.d.ts","sourceRoot":"","sources":["../src/credentials-transaction.ts"],"names":[],"mappings":"AA8DA;;;;GAIG;AACH,wBAAgB,0BAA0B,CAAC,CAAC,EAC1C,SAAS,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EAC3B,MAAM,CAAC,EAAE,WAAW,GAAG,IAAI,GAC1B,OAAO,CAAC,CAAC,CAAC,CAEZ"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Waiting for the credentials lock without freezing the process (ORI-2421).
|
|
2
|
+
// Loaded only by stored-login code (refresh, logout, login), never on the
|
|
3
|
+
// ORIZU_TOKEN / ORIZU_TOKEN_FILE path, which must not load Effect.
|
|
4
|
+
import { Cause, Effect, Exit } from 'effect';
|
|
5
|
+
import { CREDENTIALS_LOCK_RETRY_MS, credentialsLockTimeoutError, credentialsLockTimeoutMs, releaseCredentialsLock, tryAcquireCredentialsLock, withCredentialsTransactionLockAsync, } from './credentials.js';
|
|
6
|
+
/**
|
|
7
|
+
* Try the lock, sleep, try again, until ORIZU_CREDENTIALS_LOCK_TIMEOUT_MS.
|
|
8
|
+
* Timers and stop handlers keep running while it waits, and `signal` stops
|
|
9
|
+
* the wait. A try is one synchronous step, so a stop never lands inside it.
|
|
10
|
+
* A lock a try takes is noted at once, and handed back if the run then ends
|
|
11
|
+
* any way other than returning it, wherever a stop lands.
|
|
12
|
+
*/
|
|
13
|
+
function acquireLock(signal) {
|
|
14
|
+
const timeoutMs = credentialsLockTimeoutMs();
|
|
15
|
+
const deadline = Date.now() + timeoutMs;
|
|
16
|
+
let taken = null;
|
|
17
|
+
const attempt = Effect.try({
|
|
18
|
+
try: () => (taken = tryAcquireCredentialsLock()),
|
|
19
|
+
catch: cause => cause instanceof Error ? cause : new Error(String(cause)),
|
|
20
|
+
});
|
|
21
|
+
const program = Effect.gen(function* () {
|
|
22
|
+
while (true) {
|
|
23
|
+
const lock = yield* attempt;
|
|
24
|
+
if (lock)
|
|
25
|
+
return lock;
|
|
26
|
+
const remainingMs = deadline - Date.now();
|
|
27
|
+
if (remainingMs <= 0)
|
|
28
|
+
return yield* Effect.fail(credentialsLockTimeoutError(timeoutMs));
|
|
29
|
+
yield* Effect.sleep(Math.min(CREDENTIALS_LOCK_RETRY_MS, remainingMs));
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
return run(program, signal, () => {
|
|
33
|
+
if (!taken)
|
|
34
|
+
return;
|
|
35
|
+
try {
|
|
36
|
+
releaseCredentialsLock(taken);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// The stop or failure is what the caller needs to see.
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/** A stop by `signal` rejects with its reason, as an aborted fetch would. */
|
|
44
|
+
async function run(program, signal, onNotReturned) {
|
|
45
|
+
const exit = await Effect.runPromiseExit(program, signal ? { signal } : undefined);
|
|
46
|
+
if (Exit.isSuccess(exit))
|
|
47
|
+
return exit.value;
|
|
48
|
+
onNotReturned();
|
|
49
|
+
if (signal?.aborted && Cause.hasInterruptsOnly(exit.cause))
|
|
50
|
+
throw signal.reason;
|
|
51
|
+
throw Cause.squash(exit.cause);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A credentials transaction whose wait for the lock stays responsive. The
|
|
55
|
+
* in-process rules are credentials.ts's: one holder per process, and nested
|
|
56
|
+
* writes only from the holder's own async context.
|
|
57
|
+
*/
|
|
58
|
+
export function withCredentialsTransaction(operation, signal) {
|
|
59
|
+
return withCredentialsTransactionLockAsync(operation, () => acquireLock(signal));
|
|
60
|
+
}
|
package/dist/credentials.d.ts
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
import { ServerCredentials, StoredCredentialsV2, StoredCredentialsV3 } from './types.js';
|
|
2
2
|
export declare function getConfigDir(): string;
|
|
3
|
+
export declare const CREDENTIALS_LOCK_RETRY_MS = 20;
|
|
4
|
+
export interface HeldCredentialsLock {
|
|
5
|
+
path: string;
|
|
6
|
+
metadata: string;
|
|
7
|
+
depth: number;
|
|
8
|
+
asyncOwner?: symbol;
|
|
9
|
+
released: Promise<void>;
|
|
10
|
+
resolveReleased: () => void;
|
|
11
|
+
}
|
|
12
|
+
export declare function credentialsLockTimeoutMs(): number;
|
|
13
|
+
export declare function credentialsLockTimeoutError(timeoutMs: number): Error;
|
|
14
|
+
/**
|
|
15
|
+
* One try at the cross-process lock. Returns null when another owner holds
|
|
16
|
+
* it. The waits between tries belong to the caller: a blocking one below for
|
|
17
|
+
* synchronous writers, an Effect sleep in credentials-transaction.ts.
|
|
18
|
+
*/
|
|
19
|
+
export declare function tryAcquireCredentialsLock(): HeldCredentialsLock | null;
|
|
20
|
+
export declare function releaseCredentialsLock(lock: HeldCredentialsLock): void;
|
|
3
21
|
/**
|
|
4
22
|
* Runs a synchronous credentials transaction under the one process-wide and
|
|
5
23
|
* cross-process lock. Exported so real-process fixtures can hold the production
|
|
@@ -11,7 +29,7 @@ export declare function withCredentialsTransactionLock<T>(operation: () => T): T
|
|
|
11
29
|
* synchronous credential writes are admitted only from this transaction's
|
|
12
30
|
* async execution context; unrelated same-process work must wait or fail.
|
|
13
31
|
*/
|
|
14
|
-
export declare function withCredentialsTransactionLockAsync<T>(operation: () => Promise<T>): Promise<T>;
|
|
32
|
+
export declare function withCredentialsTransactionLockAsync<T>(operation: () => Promise<T>, acquire?: () => HeldCredentialsLock | Promise<HeldCredentialsLock>): Promise<T>;
|
|
15
33
|
export declare function loadCredentialsConfig(): StoredCredentialsV2 | StoredCredentialsV3 | null;
|
|
16
34
|
export declare function getServerCredentials(baseUrl: string): ServerCredentials | null;
|
|
17
35
|
export declare function saveServerCredentials(baseUrl: string, credentials: ServerCredentials): void;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"credentials.d.ts","sourceRoot":"","sources":["../src/credentials.ts"],"names":[],"mappings":"AAoBA,OAAO,EACL,iBAAiB,EAEjB,mBAAmB,EACnB,mBAAmB,EACpB,MAAM,YAAY,CAAA;
|
|
1
|
+
{"version":3,"file":"credentials.d.ts","sourceRoot":"","sources":["../src/credentials.ts"],"names":[],"mappings":"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"}
|
package/dist/credentials.js
CHANGED
|
@@ -9,6 +9,11 @@ import { canonicalServerUrl, normalizeBaseUrl } from './global-flags.js';
|
|
|
9
9
|
function isNodeError(error) {
|
|
10
10
|
return error instanceof Error;
|
|
11
11
|
}
|
|
12
|
+
// The system's short error code (EACCES, EPERM), never the message: messages
|
|
13
|
+
// from fs calls carry paths, and this one goes to the terminal.
|
|
14
|
+
function errorCode(error) {
|
|
15
|
+
return isNodeError(error) && typeof error.code === 'string' ? error.code : 'unknown error';
|
|
16
|
+
}
|
|
12
17
|
export function getConfigDir() {
|
|
13
18
|
if (process.env.ORIZU_CONFIG_DIR) {
|
|
14
19
|
return process.env.ORIZU_CONFIG_DIR;
|
|
@@ -20,7 +25,7 @@ function getCredentialsPath() {
|
|
|
20
25
|
}
|
|
21
26
|
const DEFAULT_CREDENTIALS_LOCK_TIMEOUT_MS = 2_000;
|
|
22
27
|
const MAX_CREDENTIALS_LOCK_TIMEOUT_MS = 10_000;
|
|
23
|
-
const CREDENTIALS_LOCK_RETRY_MS = 20;
|
|
28
|
+
export const CREDENTIALS_LOCK_RETRY_MS = 20;
|
|
24
29
|
let heldCredentialsLock = null;
|
|
25
30
|
const credentialsTransactionOwner = new AsyncLocalStorage();
|
|
26
31
|
function getCredentialsLockPath() {
|
|
@@ -38,58 +43,74 @@ function readLockDuration(name, fallback, maximum = Number.MAX_SAFE_INTEGER) {
|
|
|
38
43
|
function sleepSync(milliseconds) {
|
|
39
44
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
|
40
45
|
}
|
|
41
|
-
function
|
|
46
|
+
export function credentialsLockTimeoutMs() {
|
|
47
|
+
return readLockDuration('ORIZU_CREDENTIALS_LOCK_TIMEOUT_MS', DEFAULT_CREDENTIALS_LOCK_TIMEOUT_MS, MAX_CREDENTIALS_LOCK_TIMEOUT_MS);
|
|
48
|
+
}
|
|
49
|
+
export function credentialsLockTimeoutError(timeoutMs) {
|
|
50
|
+
return new Error(`ORIZU_CREDENTIALS_LOCK_TIMEOUT: credentials mutation did not acquire the lock within ${timeoutMs}ms. Verify no Orizu process is running, then remove credentials.json.lock manually.`);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* One try at the cross-process lock. Returns null when another owner holds
|
|
54
|
+
* it. The waits between tries belong to the caller: a blocking one below for
|
|
55
|
+
* synchronous writers, an Effect sleep in credentials-transaction.ts.
|
|
56
|
+
*/
|
|
57
|
+
export function tryAcquireCredentialsLock() {
|
|
42
58
|
const dir = getConfigDir();
|
|
43
59
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
44
60
|
chmodSync(dir, 0o700);
|
|
45
61
|
const lockPath = getCredentialsLockPath();
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const tempPath = join(dir, `.credentials.json.lock.${process.pid}.${randomBytes(8).toString('hex')}.tmp`);
|
|
62
|
+
const metadata = JSON.stringify({
|
|
63
|
+
version: 1,
|
|
64
|
+
pid: process.pid,
|
|
65
|
+
hostname: hostname(),
|
|
66
|
+
createdAt: Date.now(),
|
|
67
|
+
instanceId: randomBytes(16).toString('hex'),
|
|
68
|
+
});
|
|
69
|
+
const tempPath = join(dir, `.credentials.json.lock.${process.pid}.${randomBytes(8).toString('hex')}.tmp`);
|
|
70
|
+
try {
|
|
71
|
+
const fd = openSync(tempPath, 'wx', 0o600);
|
|
57
72
|
try {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
fsyncSync(fd);
|
|
62
|
-
chmodSync(tempPath, 0o600);
|
|
63
|
-
}
|
|
64
|
-
finally {
|
|
65
|
-
closeSync(fd);
|
|
66
|
-
}
|
|
67
|
-
try {
|
|
68
|
-
// link(2) publishes the already-complete inode without replacing an
|
|
69
|
-
// existing owner. No contender ever unlinks the canonical lock.
|
|
70
|
-
linkSync(tempPath, lockPath);
|
|
71
|
-
}
|
|
72
|
-
catch (error) {
|
|
73
|
-
if (!(isNodeError(error) && error.code === 'EEXIST'))
|
|
74
|
-
throw error;
|
|
75
|
-
if (Date.now() >= deadline) {
|
|
76
|
-
throw new Error(`ORIZU_CREDENTIALS_LOCK_TIMEOUT: credentials mutation did not acquire the lock within ${timeoutMs}ms. Verify no Orizu process is running, then remove credentials.json.lock manually.`);
|
|
77
|
-
}
|
|
78
|
-
sleepSync(Math.min(CREDENTIALS_LOCK_RETRY_MS, Math.max(1, deadline - Date.now())));
|
|
79
|
-
continue;
|
|
80
|
-
}
|
|
81
|
-
let resolveReleased = () => { };
|
|
82
|
-
const released = new Promise(resolve => { resolveReleased = resolve; });
|
|
83
|
-
return { path: lockPath, metadata, depth: 1, released, resolveReleased };
|
|
73
|
+
writeFileSync(fd, metadata, 'utf8');
|
|
74
|
+
fsyncSync(fd);
|
|
75
|
+
chmodSync(tempPath, 0o600);
|
|
84
76
|
}
|
|
85
77
|
finally {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
78
|
+
closeSync(fd);
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
// link(2) publishes the already-complete inode without replacing an
|
|
82
|
+
// existing owner. No contender ever unlinks the canonical lock.
|
|
83
|
+
linkSync(tempPath, lockPath);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (isNodeError(error) && error.code === 'EEXIST')
|
|
87
|
+
return null;
|
|
88
|
+
throw error;
|
|
89
89
|
}
|
|
90
|
+
let resolveReleased = () => { };
|
|
91
|
+
const released = new Promise(resolve => { resolveReleased = resolve; });
|
|
92
|
+
return { path: lockPath, metadata, depth: 1, released, resolveReleased };
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
// The unique owner-only preparation link is never retained, whether
|
|
96
|
+
// publication succeeds, loses to an owner, or fails unexpectedly.
|
|
97
|
+
rmSync(tempPath, { force: true });
|
|
90
98
|
}
|
|
91
99
|
}
|
|
92
|
-
|
|
100
|
+
/** Blocks the process between tries; only for synchronous writers. */
|
|
101
|
+
function acquireCredentialsLock() {
|
|
102
|
+
const timeoutMs = credentialsLockTimeoutMs();
|
|
103
|
+
const deadline = Date.now() + timeoutMs;
|
|
104
|
+
while (true) {
|
|
105
|
+
const lock = tryAcquireCredentialsLock();
|
|
106
|
+
if (lock)
|
|
107
|
+
return lock;
|
|
108
|
+
if (Date.now() >= deadline)
|
|
109
|
+
throw credentialsLockTimeoutError(timeoutMs);
|
|
110
|
+
sleepSync(Math.min(CREDENTIALS_LOCK_RETRY_MS, Math.max(1, deadline - Date.now())));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export function releaseCredentialsLock(lock) {
|
|
93
114
|
let currentMetadata;
|
|
94
115
|
try {
|
|
95
116
|
currentMetadata = readFileSync(lock.path, 'utf8');
|
|
@@ -129,17 +150,30 @@ export function withCredentialsTransactionLock(operation) {
|
|
|
129
150
|
}
|
|
130
151
|
const lock = acquireCredentialsLock();
|
|
131
152
|
heldCredentialsLock = lock;
|
|
153
|
+
let failed = false;
|
|
132
154
|
try {
|
|
133
155
|
return operation();
|
|
134
156
|
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
failed = true;
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
135
161
|
finally {
|
|
136
162
|
heldCredentialsLock = null;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
163
|
+
endTransaction(lock, failed);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Release the lock if it is still ours. A release error never hides the transaction's own error. */
|
|
167
|
+
function endTransaction(lock, failed) {
|
|
168
|
+
try {
|
|
169
|
+
releaseCredentialsLock(lock);
|
|
170
|
+
}
|
|
171
|
+
catch (releaseError) {
|
|
172
|
+
if (!failed)
|
|
173
|
+
throw releaseError;
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
lock.resolveReleased();
|
|
143
177
|
}
|
|
144
178
|
}
|
|
145
179
|
/**
|
|
@@ -147,7 +181,10 @@ export function withCredentialsTransactionLock(operation) {
|
|
|
147
181
|
* synchronous credential writes are admitted only from this transaction's
|
|
148
182
|
* async execution context; unrelated same-process work must wait or fail.
|
|
149
183
|
*/
|
|
150
|
-
export async function withCredentialsTransactionLockAsync(operation
|
|
184
|
+
export async function withCredentialsTransactionLockAsync(operation,
|
|
185
|
+
// How to wait for the cross-process lock. The default blocks the process
|
|
186
|
+
// between tries; stored-login commands pass credentials-transaction.ts's.
|
|
187
|
+
acquire = acquireCredentialsLock) {
|
|
151
188
|
const lockPath = getCredentialsLockPath();
|
|
152
189
|
const currentOwner = credentialsTransactionOwner.getStore();
|
|
153
190
|
if (heldCredentialsLock) {
|
|
@@ -163,7 +200,7 @@ export async function withCredentialsTransactionLockAsync(operation) {
|
|
|
163
200
|
heldCredentialsLock.depth -= 1;
|
|
164
201
|
}
|
|
165
202
|
}
|
|
166
|
-
const timeoutMs =
|
|
203
|
+
const timeoutMs = credentialsLockTimeoutMs();
|
|
167
204
|
let timer;
|
|
168
205
|
const didRelease = await Promise.race([
|
|
169
206
|
heldCredentialsLock.released.then(() => true),
|
|
@@ -171,26 +208,25 @@ export async function withCredentialsTransactionLockAsync(operation) {
|
|
|
171
208
|
]);
|
|
172
209
|
if (timer)
|
|
173
210
|
clearTimeout(timer);
|
|
174
|
-
if (!didRelease)
|
|
175
|
-
throw
|
|
176
|
-
|
|
177
|
-
return await withCredentialsTransactionLockAsync(operation);
|
|
211
|
+
if (!didRelease)
|
|
212
|
+
throw credentialsLockTimeoutError(timeoutMs);
|
|
213
|
+
return await withCredentialsTransactionLockAsync(operation, acquire);
|
|
178
214
|
}
|
|
179
|
-
const lock =
|
|
215
|
+
const lock = await acquire();
|
|
180
216
|
const owner = Symbol('credentials-transaction-owner');
|
|
181
217
|
lock.asyncOwner = owner;
|
|
182
218
|
heldCredentialsLock = lock;
|
|
219
|
+
let failed = false;
|
|
183
220
|
try {
|
|
184
221
|
return await credentialsTransactionOwner.run(owner, operation);
|
|
185
222
|
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
failed = true;
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
186
227
|
finally {
|
|
187
228
|
heldCredentialsLock = null;
|
|
188
|
-
|
|
189
|
-
releaseCredentialsLock(lock);
|
|
190
|
-
}
|
|
191
|
-
finally {
|
|
192
|
-
lock.resolveReleased();
|
|
193
|
-
}
|
|
229
|
+
endTransaction(lock, failed);
|
|
194
230
|
}
|
|
195
231
|
}
|
|
196
232
|
function assertCredentialsLockHeld() {
|
|
@@ -244,18 +280,24 @@ function writeCredentials(config) {
|
|
|
244
280
|
const tempPath = join(dir, `.credentials.json.${process.pid}.${randomBytes(8).toString('hex')}.tmp`);
|
|
245
281
|
const payload = JSON.stringify(config, null, 2) + '\n';
|
|
246
282
|
try {
|
|
247
|
-
const fd = openSync(tempPath, 'wx', 0o600);
|
|
248
283
|
try {
|
|
249
|
-
|
|
250
|
-
|
|
284
|
+
const fd = openSync(tempPath, 'wx', 0o600);
|
|
285
|
+
try {
|
|
286
|
+
writeFileSync(fd, payload, 'utf-8');
|
|
287
|
+
fsyncSync(fd);
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
closeSync(fd);
|
|
291
|
+
}
|
|
292
|
+
chmodSync(tempPath, 0o600);
|
|
293
|
+
// Never delete the destination to emulate replacement. If this platform
|
|
294
|
+
// cannot atomically replace it, preserve the old file and surface the error.
|
|
295
|
+
renameSync(tempPath, path);
|
|
251
296
|
}
|
|
252
|
-
|
|
253
|
-
|
|
297
|
+
catch (error) {
|
|
298
|
+
// Until the rename succeeds the saved file is untouched.
|
|
299
|
+
throw new Error(`ORIZU_CREDENTIALS_WRITE_FAILED: could not save sign-ins to ${path} (${errorCode(error)}). The saved file is unchanged.`, { cause: error });
|
|
254
300
|
}
|
|
255
|
-
chmodSync(tempPath, 0o600);
|
|
256
|
-
// Never delete the destination to emulate replacement. If this platform
|
|
257
|
-
// cannot atomically replace it, preserve the old file and surface the error.
|
|
258
|
-
renameSync(tempPath, path);
|
|
259
301
|
chmodSync(path, 0o600);
|
|
260
302
|
}
|
|
261
303
|
finally {
|
|
@@ -271,12 +313,15 @@ function createEmptyCredentialsConfig() {
|
|
|
271
313
|
servers: {},
|
|
272
314
|
};
|
|
273
315
|
}
|
|
316
|
+
// A missing file, invalid JSON or an unknown format start empty (the "log in
|
|
317
|
+
// again" recovery). A file that exists but cannot be read is never replaced:
|
|
318
|
+
// that would delete every other saved sign-in.
|
|
274
319
|
function loadCredentialsConfigForWrite() {
|
|
275
320
|
try {
|
|
276
321
|
return loadCredentialsConfig() || createEmptyCredentialsConfig();
|
|
277
322
|
}
|
|
278
|
-
catch {
|
|
279
|
-
|
|
323
|
+
catch (error) {
|
|
324
|
+
throw new Error(`ORIZU_CREDENTIALS_READ_FAILED: could not read ${getCredentialsPath()} (${errorCode(error)}). Saved sign-ins were not changed. Move it aside, then run \`orizu login\` again.`, { cause: error });
|
|
280
325
|
}
|
|
281
326
|
}
|
|
282
327
|
export function loadCredentialsConfig() {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stored-session-requests.d.ts","sourceRoot":"","sources":["../src/stored-session-requests.ts"],"names":[],"mappings":"AAYA,OAAO,EAAE,iBAAiB,EAA4B,MAAM,YAAY,CAAA;
|
|
1
|
+
{"version":3,"file":"stored-session-requests.d.ts","sourceRoot":"","sources":["../src/stored-session-requests.ts"],"names":[],"mappings":"AAYA,OAAO,EAAE,iBAAiB,EAA4B,MAAM,YAAY,CAAA;AA+MxE,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,MAAM,CAAA;IACf,WAAW,EAAE,iBAAiB,CAAA;IAC9B,YAAY,EAAE,OAAO,CAAA;IACrB,2GAA2G;IAC3G,oBAAoB,EAAE,MAAM,CAAA;IAC5B,mBAAmB,EAAE,MAAM,CAAA;IAC3B,iGAAiG;IACjG,WAAW,CAAC,EAAE,CAAC,WAAW,EAAE,iBAAiB,KAAK,IAAI,CAAA;CACvD;AAED;;;;GAIG;AACH,wBAAsB,aAAa,CACjC,OAAO,EAAE,oBAAoB,EAC7B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,WAAW,EACjB,cAAc,CAAC,EAAE,MAAM,IAAI,GAC1B,OAAO,CAAC,QAAQ,CAAC,CAiCnB;AAED,MAAM,WAAW,YAAY;IAC3B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,sBAAsB,EAAE,OAAO,CAAA;CAChC;AAED;;;GAGG;AACH,wBAAsB,YAAY,CAChC,OAAO,EAAE,MAAM,EACf,aAAa,EAAE,MAAM,EACrB,WAAW,EAAE,iBAAiB,EAC9B,YAAY,CAAC,EAAE,WAAW,GAAG,IAAI,GAChC,OAAO,CAAC,YAAY,CAAC,CA6BvB"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Cause, Effect, Exit, Result, Schema } from 'effect';
|
|
2
|
-
import { clearServerCredentialsIfCurrent, credentialsEqual, getServerCredentials, rememberResolvedBearer, updateServerCredentialsIfCurrent,
|
|
2
|
+
import { clearServerCredentialsIfCurrent, credentialsEqual, getServerCredentials, rememberResolvedBearer, updateServerCredentialsIfCurrent, } from './credentials.js';
|
|
3
|
+
import { withCredentialsTransaction } from './credentials-transaction.js';
|
|
3
4
|
import { assertSecureTokenTransport, credentialRequestRedirectPolicy } from './http.js';
|
|
4
5
|
import { describeLogoutHttpFailure, describeLogoutTransportFailure } from './logout-diagnostic.js';
|
|
5
6
|
// Requests signed with a stored login (`orizu login`): the sign-in refresh, the
|
|
@@ -156,7 +157,9 @@ async function refreshStoredCredentialsIfCurrent(baseUrl, expected, callerSignal
|
|
|
156
157
|
const stop = lockStopWindow(callerSignal);
|
|
157
158
|
stop.listen();
|
|
158
159
|
try {
|
|
159
|
-
|
|
160
|
+
// A stop or the caller's cancel also ends the wait for the lock.
|
|
161
|
+
const waitSignal = callerSignal ? AbortSignal.any([callerSignal, stop.signal]) : stop.signal;
|
|
162
|
+
return await withCredentialsTransaction(async () => {
|
|
160
163
|
const current = getServerCredentials(baseUrl);
|
|
161
164
|
if (!current || !credentialsEqual(current, expected)) {
|
|
162
165
|
return { status: 'changed', credentials: current };
|
|
@@ -167,7 +170,7 @@ async function refreshStoredCredentialsIfCurrent(baseUrl, expected, callerSignal
|
|
|
167
170
|
return { status: 'changed', credentials: getServerCredentials(baseUrl) };
|
|
168
171
|
}
|
|
169
172
|
return { status: 'refreshed', credentials: refreshed };
|
|
170
|
-
});
|
|
173
|
+
}, waitSignal);
|
|
171
174
|
}
|
|
172
175
|
finally {
|
|
173
176
|
stop.unlisten();
|
|
@@ -259,6 +262,7 @@ export async function storedLogout(baseUrl, credentialKey, credentials, callerSi
|
|
|
259
262
|
catch (error) {
|
|
260
263
|
remoteError = describeLogoutTransportFailure(error, secrets);
|
|
261
264
|
}
|
|
262
|
-
|
|
265
|
+
// Not stopped by the caller's cancel: a cancelled logout still removes the login, as before.
|
|
266
|
+
const localCredentialCleared = await withCredentialsTransaction(async () => clearServerCredentialsIfCurrent(credentialKey, credentials));
|
|
263
267
|
return { remoteError, localCredentialCleared };
|
|
264
268
|
}
|
package/package.json
CHANGED
package/src/auth-commands.ts
CHANGED
|
@@ -142,6 +142,10 @@ interface AuthenticatedLoginResponse extends LoginResponse {
|
|
|
142
142
|
apiKey: string
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
function isCredentialsFileFailure(message: string): boolean {
|
|
146
|
+
return message.startsWith('ORIZU_CREDENTIALS_READ_FAILED:') || message.startsWith('ORIZU_CREDENTIALS_WRITE_FAILED:')
|
|
147
|
+
}
|
|
148
|
+
|
|
145
149
|
async function login(baseUrlOverride?: string) {
|
|
146
150
|
const target = baseUrlOverride !== undefined
|
|
147
151
|
? { baseUrl: baseUrlOverride, isDefault: false }
|
|
@@ -153,13 +157,14 @@ async function login(baseUrlOverride?: string) {
|
|
|
153
157
|
try {
|
|
154
158
|
loginData = await authenticateAtBaseUrl(baseUrl)
|
|
155
159
|
if (!loginData) return
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
})
|
|
160
|
+
const apiKey = loginData.apiKey
|
|
161
|
+
// Waits for the lock without freezing the CLI (ORI-2421).
|
|
162
|
+
const { withCredentialsTransaction } = await import('./credentials-transaction.js')
|
|
163
|
+
await withCredentialsTransaction(async () => saveServerCredentials(baseUrl, { credentialType: 'pat', apiKey }))
|
|
160
164
|
} catch (error) {
|
|
161
|
-
|
|
162
|
-
|
|
165
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
166
|
+
// Reading or saving credentials.json is a disk problem, not a connection one.
|
|
167
|
+
if (target.isDefault && !isCredentialsFileFailure(detail)) {
|
|
163
168
|
throw new Error(
|
|
164
169
|
`${detail} Check connectivity to https://app.orizu.ai, then retry \`orizu login\`.`,
|
|
165
170
|
{ cause: error }
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Waiting for the credentials lock without freezing the process (ORI-2421).
|
|
2
|
+
// Loaded only by stored-login code (refresh, logout, login), never on the
|
|
3
|
+
// ORIZU_TOKEN / ORIZU_TOKEN_FILE path, which must not load Effect.
|
|
4
|
+
import { Cause, Effect, Exit } from 'effect'
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
CREDENTIALS_LOCK_RETRY_MS,
|
|
8
|
+
credentialsLockTimeoutError,
|
|
9
|
+
credentialsLockTimeoutMs,
|
|
10
|
+
type HeldCredentialsLock,
|
|
11
|
+
releaseCredentialsLock,
|
|
12
|
+
tryAcquireCredentialsLock,
|
|
13
|
+
withCredentialsTransactionLockAsync,
|
|
14
|
+
} from './credentials.js'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Try the lock, sleep, try again, until ORIZU_CREDENTIALS_LOCK_TIMEOUT_MS.
|
|
18
|
+
* Timers and stop handlers keep running while it waits, and `signal` stops
|
|
19
|
+
* the wait. A try is one synchronous step, so a stop never lands inside it.
|
|
20
|
+
* A lock a try takes is noted at once, and handed back if the run then ends
|
|
21
|
+
* any way other than returning it, wherever a stop lands.
|
|
22
|
+
*/
|
|
23
|
+
function acquireLock(signal: AbortSignal | null | undefined): Promise<HeldCredentialsLock> {
|
|
24
|
+
const timeoutMs = credentialsLockTimeoutMs()
|
|
25
|
+
const deadline = Date.now() + timeoutMs
|
|
26
|
+
let taken: HeldCredentialsLock | null = null
|
|
27
|
+
const attempt = Effect.try({
|
|
28
|
+
try: () => (taken = tryAcquireCredentialsLock()),
|
|
29
|
+
catch: cause => cause instanceof Error ? cause : new Error(String(cause)),
|
|
30
|
+
})
|
|
31
|
+
const program = Effect.gen(function*() {
|
|
32
|
+
while (true) {
|
|
33
|
+
const lock = yield* attempt
|
|
34
|
+
if (lock) return lock
|
|
35
|
+
const remainingMs = deadline - Date.now()
|
|
36
|
+
if (remainingMs <= 0) return yield* Effect.fail(credentialsLockTimeoutError(timeoutMs))
|
|
37
|
+
yield* Effect.sleep(Math.min(CREDENTIALS_LOCK_RETRY_MS, remainingMs))
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
return run(program, signal, () => {
|
|
41
|
+
if (!taken) return
|
|
42
|
+
try {
|
|
43
|
+
releaseCredentialsLock(taken)
|
|
44
|
+
} catch {
|
|
45
|
+
// The stop or failure is what the caller needs to see.
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A stop by `signal` rejects with its reason, as an aborted fetch would. */
|
|
51
|
+
async function run<A>(
|
|
52
|
+
program: Effect.Effect<A, Error>,
|
|
53
|
+
signal: AbortSignal | null | undefined,
|
|
54
|
+
onNotReturned: () => void
|
|
55
|
+
): Promise<A> {
|
|
56
|
+
const exit = await Effect.runPromiseExit(program, signal ? { signal } : undefined)
|
|
57
|
+
if (Exit.isSuccess(exit)) return exit.value
|
|
58
|
+
onNotReturned()
|
|
59
|
+
if (signal?.aborted && Cause.hasInterruptsOnly(exit.cause)) throw signal.reason
|
|
60
|
+
throw Cause.squash(exit.cause)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A credentials transaction whose wait for the lock stays responsive. The
|
|
65
|
+
* in-process rules are credentials.ts's: one holder per process, and nested
|
|
66
|
+
* writes only from the holder's own async context.
|
|
67
|
+
*/
|
|
68
|
+
export function withCredentialsTransaction<T>(
|
|
69
|
+
operation: () => Promise<T>,
|
|
70
|
+
signal?: AbortSignal | null
|
|
71
|
+
): Promise<T> {
|
|
72
|
+
return withCredentialsTransactionLockAsync(operation, () => acquireLock(signal))
|
|
73
|
+
}
|
package/src/credentials.ts
CHANGED
|
@@ -29,6 +29,12 @@ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
|
29
29
|
return error instanceof Error
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// The system's short error code (EACCES, EPERM), never the message: messages
|
|
33
|
+
// from fs calls carry paths, and this one goes to the terminal.
|
|
34
|
+
function errorCode(error: unknown): string {
|
|
35
|
+
return isNodeError(error) && typeof error.code === 'string' ? error.code : 'unknown error'
|
|
36
|
+
}
|
|
37
|
+
|
|
32
38
|
export function getConfigDir(): string {
|
|
33
39
|
if (process.env.ORIZU_CONFIG_DIR) {
|
|
34
40
|
return process.env.ORIZU_CONFIG_DIR
|
|
@@ -42,7 +48,7 @@ function getCredentialsPath(): string {
|
|
|
42
48
|
|
|
43
49
|
const DEFAULT_CREDENTIALS_LOCK_TIMEOUT_MS = 2_000
|
|
44
50
|
const MAX_CREDENTIALS_LOCK_TIMEOUT_MS = 10_000
|
|
45
|
-
const CREDENTIALS_LOCK_RETRY_MS = 20
|
|
51
|
+
export const CREDENTIALS_LOCK_RETRY_MS = 20
|
|
46
52
|
|
|
47
53
|
interface CredentialsLockMetadata {
|
|
48
54
|
version: 1
|
|
@@ -52,7 +58,7 @@ interface CredentialsLockMetadata {
|
|
|
52
58
|
instanceId: string
|
|
53
59
|
}
|
|
54
60
|
|
|
55
|
-
interface HeldCredentialsLock {
|
|
61
|
+
export interface HeldCredentialsLock {
|
|
56
62
|
path: string
|
|
57
63
|
metadata: string
|
|
58
64
|
depth: number
|
|
@@ -81,69 +87,85 @@ function sleepSync(milliseconds: number): void {
|
|
|
81
87
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds)
|
|
82
88
|
}
|
|
83
89
|
|
|
84
|
-
function
|
|
90
|
+
export function credentialsLockTimeoutMs(): number {
|
|
91
|
+
return readLockDuration(
|
|
92
|
+
'ORIZU_CREDENTIALS_LOCK_TIMEOUT_MS',
|
|
93
|
+
DEFAULT_CREDENTIALS_LOCK_TIMEOUT_MS,
|
|
94
|
+
MAX_CREDENTIALS_LOCK_TIMEOUT_MS
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function credentialsLockTimeoutError(timeoutMs: number): Error {
|
|
99
|
+
return new Error(
|
|
100
|
+
`ORIZU_CREDENTIALS_LOCK_TIMEOUT: credentials mutation did not acquire the lock within ${timeoutMs}ms. Verify no Orizu process is running, then remove credentials.json.lock manually.`
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* One try at the cross-process lock. Returns null when another owner holds
|
|
106
|
+
* it. The waits between tries belong to the caller: a blocking one below for
|
|
107
|
+
* synchronous writers, an Effect sleep in credentials-transaction.ts.
|
|
108
|
+
*/
|
|
109
|
+
export function tryAcquireCredentialsLock(): HeldCredentialsLock | null {
|
|
85
110
|
const dir = getConfigDir()
|
|
86
111
|
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
87
112
|
chmodSync(dir, 0o700)
|
|
88
113
|
|
|
89
114
|
const lockPath = getCredentialsLockPath()
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
115
|
+
const metadata = JSON.stringify({
|
|
116
|
+
version: 1,
|
|
117
|
+
pid: process.pid,
|
|
118
|
+
hostname: hostname(),
|
|
119
|
+
createdAt: Date.now(),
|
|
120
|
+
instanceId: randomBytes(16).toString('hex'),
|
|
121
|
+
} satisfies CredentialsLockMetadata)
|
|
122
|
+
const tempPath = join(
|
|
123
|
+
dir,
|
|
124
|
+
`.credentials.json.lock.${process.pid}.${randomBytes(8).toString('hex')}.tmp`
|
|
94
125
|
)
|
|
95
|
-
const deadline = Date.now() + timeoutMs
|
|
96
126
|
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
dir,
|
|
107
|
-
`.credentials.json.lock.${process.pid}.${randomBytes(8).toString('hex')}.tmp`
|
|
108
|
-
)
|
|
127
|
+
try {
|
|
128
|
+
const fd = openSync(tempPath, 'wx', 0o600)
|
|
129
|
+
try {
|
|
130
|
+
writeFileSync(fd, metadata, 'utf8')
|
|
131
|
+
fsyncSync(fd)
|
|
132
|
+
chmodSync(tempPath, 0o600)
|
|
133
|
+
} finally {
|
|
134
|
+
closeSync(fd)
|
|
135
|
+
}
|
|
109
136
|
|
|
110
137
|
try {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
138
|
+
// link(2) publishes the already-complete inode without replacing an
|
|
139
|
+
// existing owner. No contender ever unlinks the canonical lock.
|
|
140
|
+
linkSync(tempPath, lockPath)
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (isNodeError(error) && error.code === 'EEXIST') return null
|
|
143
|
+
throw error
|
|
144
|
+
}
|
|
119
145
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
)
|
|
130
|
-
}
|
|
131
|
-
sleepSync(Math.min(CREDENTIALS_LOCK_RETRY_MS, Math.max(1, deadline - Date.now())))
|
|
132
|
-
continue
|
|
133
|
-
}
|
|
146
|
+
let resolveReleased = () => {}
|
|
147
|
+
const released = new Promise<void>(resolve => { resolveReleased = resolve })
|
|
148
|
+
return { path: lockPath, metadata, depth: 1, released, resolveReleased }
|
|
149
|
+
} finally {
|
|
150
|
+
// The unique owner-only preparation link is never retained, whether
|
|
151
|
+
// publication succeeds, loses to an owner, or fails unexpectedly.
|
|
152
|
+
rmSync(tempPath, { force: true })
|
|
153
|
+
}
|
|
154
|
+
}
|
|
134
155
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
156
|
+
/** Blocks the process between tries; only for synchronous writers. */
|
|
157
|
+
function acquireCredentialsLock(): HeldCredentialsLock {
|
|
158
|
+
const timeoutMs = credentialsLockTimeoutMs()
|
|
159
|
+
const deadline = Date.now() + timeoutMs
|
|
160
|
+
while (true) {
|
|
161
|
+
const lock = tryAcquireCredentialsLock()
|
|
162
|
+
if (lock) return lock
|
|
163
|
+
if (Date.now() >= deadline) throw credentialsLockTimeoutError(timeoutMs)
|
|
164
|
+
sleepSync(Math.min(CREDENTIALS_LOCK_RETRY_MS, Math.max(1, deadline - Date.now())))
|
|
143
165
|
}
|
|
144
166
|
}
|
|
145
167
|
|
|
146
|
-
function releaseCredentialsLock(lock: HeldCredentialsLock): void {
|
|
168
|
+
export function releaseCredentialsLock(lock: HeldCredentialsLock): void {
|
|
147
169
|
let currentMetadata: string
|
|
148
170
|
try {
|
|
149
171
|
currentMetadata = readFileSync(lock.path, 'utf8')
|
|
@@ -183,15 +205,26 @@ export function withCredentialsTransactionLock<T>(operation: () => T): T {
|
|
|
183
205
|
|
|
184
206
|
const lock = acquireCredentialsLock()
|
|
185
207
|
heldCredentialsLock = lock
|
|
208
|
+
let failed = false
|
|
186
209
|
try {
|
|
187
210
|
return operation()
|
|
211
|
+
} catch (error) {
|
|
212
|
+
failed = true
|
|
213
|
+
throw error
|
|
188
214
|
} finally {
|
|
189
215
|
heldCredentialsLock = null
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
216
|
+
endTransaction(lock, failed)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Release the lock if it is still ours. A release error never hides the transaction's own error. */
|
|
221
|
+
function endTransaction(lock: HeldCredentialsLock, failed: boolean): void {
|
|
222
|
+
try {
|
|
223
|
+
releaseCredentialsLock(lock)
|
|
224
|
+
} catch (releaseError) {
|
|
225
|
+
if (!failed) throw releaseError
|
|
226
|
+
} finally {
|
|
227
|
+
lock.resolveReleased()
|
|
195
228
|
}
|
|
196
229
|
}
|
|
197
230
|
|
|
@@ -201,7 +234,10 @@ export function withCredentialsTransactionLock<T>(operation: () => T): T {
|
|
|
201
234
|
* async execution context; unrelated same-process work must wait or fail.
|
|
202
235
|
*/
|
|
203
236
|
export async function withCredentialsTransactionLockAsync<T>(
|
|
204
|
-
operation: () => Promise<T
|
|
237
|
+
operation: () => Promise<T>,
|
|
238
|
+
// How to wait for the cross-process lock. The default blocks the process
|
|
239
|
+
// between tries; stored-login commands pass credentials-transaction.ts's.
|
|
240
|
+
acquire: () => HeldCredentialsLock | Promise<HeldCredentialsLock> = acquireCredentialsLock
|
|
205
241
|
): Promise<T> {
|
|
206
242
|
const lockPath = getCredentialsLockPath()
|
|
207
243
|
const currentOwner = credentialsTransactionOwner.getStore()
|
|
@@ -218,38 +254,30 @@ export async function withCredentialsTransactionLockAsync<T>(
|
|
|
218
254
|
}
|
|
219
255
|
}
|
|
220
256
|
|
|
221
|
-
const timeoutMs =
|
|
222
|
-
'ORIZU_CREDENTIALS_LOCK_TIMEOUT_MS',
|
|
223
|
-
DEFAULT_CREDENTIALS_LOCK_TIMEOUT_MS,
|
|
224
|
-
MAX_CREDENTIALS_LOCK_TIMEOUT_MS
|
|
225
|
-
)
|
|
257
|
+
const timeoutMs = credentialsLockTimeoutMs()
|
|
226
258
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
227
259
|
const didRelease = await Promise.race([
|
|
228
260
|
heldCredentialsLock.released.then(() => true),
|
|
229
261
|
new Promise<false>(resolve => { timer = setTimeout(() => resolve(false), timeoutMs) }),
|
|
230
262
|
])
|
|
231
263
|
if (timer) clearTimeout(timer)
|
|
232
|
-
if (!didRelease)
|
|
233
|
-
|
|
234
|
-
`ORIZU_CREDENTIALS_LOCK_TIMEOUT: credentials mutation did not acquire the lock within ${timeoutMs}ms. Verify no Orizu process is running, then remove credentials.json.lock manually.`
|
|
235
|
-
)
|
|
236
|
-
}
|
|
237
|
-
return await withCredentialsTransactionLockAsync(operation)
|
|
264
|
+
if (!didRelease) throw credentialsLockTimeoutError(timeoutMs)
|
|
265
|
+
return await withCredentialsTransactionLockAsync(operation, acquire)
|
|
238
266
|
}
|
|
239
267
|
|
|
240
|
-
const lock =
|
|
268
|
+
const lock = await acquire()
|
|
241
269
|
const owner = Symbol('credentials-transaction-owner')
|
|
242
270
|
lock.asyncOwner = owner
|
|
243
271
|
heldCredentialsLock = lock
|
|
272
|
+
let failed = false
|
|
244
273
|
try {
|
|
245
274
|
return await credentialsTransactionOwner.run(owner, operation)
|
|
275
|
+
} catch (error) {
|
|
276
|
+
failed = true
|
|
277
|
+
throw error
|
|
246
278
|
} finally {
|
|
247
279
|
heldCredentialsLock = null
|
|
248
|
-
|
|
249
|
-
releaseCredentialsLock(lock)
|
|
250
|
-
} finally {
|
|
251
|
-
lock.resolveReleased()
|
|
252
|
-
}
|
|
280
|
+
endTransaction(lock, failed)
|
|
253
281
|
}
|
|
254
282
|
}
|
|
255
283
|
|
|
@@ -319,18 +347,26 @@ function writeCredentials(config: StoredCredentialsV2 | StoredCredentialsV3) {
|
|
|
319
347
|
const payload = JSON.stringify(config, null, 2) + '\n'
|
|
320
348
|
|
|
321
349
|
try {
|
|
322
|
-
const fd = openSync(tempPath, 'wx', 0o600)
|
|
323
350
|
try {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
351
|
+
const fd = openSync(tempPath, 'wx', 0o600)
|
|
352
|
+
try {
|
|
353
|
+
writeFileSync(fd, payload, 'utf-8')
|
|
354
|
+
fsyncSync(fd)
|
|
355
|
+
} finally {
|
|
356
|
+
closeSync(fd)
|
|
357
|
+
}
|
|
329
358
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
359
|
+
chmodSync(tempPath, 0o600)
|
|
360
|
+
// Never delete the destination to emulate replacement. If this platform
|
|
361
|
+
// cannot atomically replace it, preserve the old file and surface the error.
|
|
362
|
+
renameSync(tempPath, path)
|
|
363
|
+
} catch (error) {
|
|
364
|
+
// Until the rename succeeds the saved file is untouched.
|
|
365
|
+
throw new Error(
|
|
366
|
+
`ORIZU_CREDENTIALS_WRITE_FAILED: could not save sign-ins to ${path} (${errorCode(error)}). The saved file is unchanged.`,
|
|
367
|
+
{ cause: error }
|
|
368
|
+
)
|
|
369
|
+
}
|
|
334
370
|
chmodSync(path, 0o600)
|
|
335
371
|
} finally {
|
|
336
372
|
// On success rename has already removed this path; on every error this
|
|
@@ -347,11 +383,17 @@ function createEmptyCredentialsConfig(): StoredCredentialsV3 {
|
|
|
347
383
|
}
|
|
348
384
|
}
|
|
349
385
|
|
|
386
|
+
// A missing file, invalid JSON or an unknown format start empty (the "log in
|
|
387
|
+
// again" recovery). A file that exists but cannot be read is never replaced:
|
|
388
|
+
// that would delete every other saved sign-in.
|
|
350
389
|
function loadCredentialsConfigForWrite(): StoredCredentialsV2 | StoredCredentialsV3 {
|
|
351
390
|
try {
|
|
352
391
|
return loadCredentialsConfig() || createEmptyCredentialsConfig()
|
|
353
|
-
} catch {
|
|
354
|
-
|
|
392
|
+
} catch (error) {
|
|
393
|
+
throw new Error(
|
|
394
|
+
`ORIZU_CREDENTIALS_READ_FAILED: could not read ${getCredentialsPath()} (${errorCode(error)}). Saved sign-ins were not changed. Move it aside, then run \`orizu login\` again.`,
|
|
395
|
+
{ cause: error }
|
|
396
|
+
)
|
|
355
397
|
}
|
|
356
398
|
}
|
|
357
399
|
|
|
@@ -6,8 +6,8 @@ import {
|
|
|
6
6
|
getServerCredentials,
|
|
7
7
|
rememberResolvedBearer,
|
|
8
8
|
updateServerCredentialsIfCurrent,
|
|
9
|
-
withCredentialsTransactionLockAsync,
|
|
10
9
|
} from './credentials.js'
|
|
10
|
+
import { withCredentialsTransaction } from './credentials-transaction.js'
|
|
11
11
|
import { assertSecureTokenTransport, credentialRequestRedirectPolicy } from './http.js'
|
|
12
12
|
import { describeLogoutHttpFailure, describeLogoutTransportFailure } from './logout-diagnostic.js'
|
|
13
13
|
import { ServerCredentials, SessionServerCredentials } from './types.js'
|
|
@@ -182,7 +182,9 @@ async function refreshStoredCredentialsIfCurrent(
|
|
|
182
182
|
const stop = lockStopWindow(callerSignal)
|
|
183
183
|
stop.listen()
|
|
184
184
|
try {
|
|
185
|
-
|
|
185
|
+
// A stop or the caller's cancel also ends the wait for the lock.
|
|
186
|
+
const waitSignal = callerSignal ? AbortSignal.any([callerSignal, stop.signal]) : stop.signal
|
|
187
|
+
return await withCredentialsTransaction(async () => {
|
|
186
188
|
const current = getServerCredentials(baseUrl)
|
|
187
189
|
if (!current || !credentialsEqual(current, expected)) {
|
|
188
190
|
return { status: 'changed', credentials: current }
|
|
@@ -193,7 +195,7 @@ async function refreshStoredCredentialsIfCurrent(
|
|
|
193
195
|
return { status: 'changed', credentials: getServerCredentials(baseUrl) }
|
|
194
196
|
}
|
|
195
197
|
return { status: 'refreshed', credentials: refreshed }
|
|
196
|
-
})
|
|
198
|
+
}, waitSignal)
|
|
197
199
|
} finally {
|
|
198
200
|
stop.unlisten()
|
|
199
201
|
await stop.endIfStopped()
|
|
@@ -311,6 +313,7 @@ export async function storedLogout(
|
|
|
311
313
|
} catch (error) {
|
|
312
314
|
remoteError = describeLogoutTransportFailure(error, secrets)
|
|
313
315
|
}
|
|
314
|
-
|
|
316
|
+
// Not stopped by the caller's cancel: a cancelled logout still removes the login, as before.
|
|
317
|
+
const localCredentialCleared = await withCredentialsTransaction(async () => clearServerCredentialsIfCurrent(credentialKey, credentials))
|
|
315
318
|
return { remoteError, localCredentialCleared }
|
|
316
319
|
}
|