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.
@@ -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;AAwBD,iBAAe,KAAK,CAAC,eAAe,CAAC,EAAE,MAAM,iBAmC5C;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"}
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"}
@@ -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
- saveServerCredentials(baseUrl, {
117
- credentialType: 'pat',
118
- apiKey: loginData.apiKey,
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
- if (target.isDefault) {
123
- const detail = error instanceof Error ? error.message : String(error);
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
+ }
@@ -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;AAMnB,wBAAgB,YAAY,IAAI,MAAM,CAKrC;AA0HD;;;;GAIG;AACH,wBAAgB,8BAA8B,CAAC,CAAC,EAAE,SAAS,EAAE,MAAM,CAAC,GAAG,CAAC,CAgCvE;AAED;;;;GAIG;AACH,wBAAsB,mCAAmC,CAAC,CAAC,EACzD,SAAS,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAC1B,OAAO,CAAC,CAAC,CAAC,CAiDZ;AAwGD,wBAAgB,qBAAqB,IAAI,mBAAmB,GAAG,mBAAmB,GAAG,IAAI,CA8BxF;AA0BD,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,iBAAiB,GAAG,IAAI,CAuB9E;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,iBAAiB,QAepF;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,iBAAiB,QAatF;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,iBAAiB,EAAE,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAS3F;AAED,wBAAgB,gCAAgC,CAC9C,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,iBAAiB,EAC3B,WAAW,EAAE,iBAAiB,GAC7B,OAAO,CAUT;AAmBD,wBAAgB,gBAAgB,IAAI,MAAM,GAAG,IAAI,CAMhD;AAED,uFAAuF;AACvF,wBAAgB,uBAAuB,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAyCxE;AAID,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAG5D;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAE7D;AAED,wBAAgB,yBAAyB,IAAI,SAAS,MAAM,EAAE,CAE7D;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,qBAAqB,IAAI,MAAM,GAAG,IAAI,CA0BrD;AAED;;;;;;;;GAQG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAclE;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAS1D;AAED,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,QAMtD;AAED,wBAAgB,+BAA+B,CAC7C,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,iBAAiB,GAC1B,OAAO,CAWT;AAED,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAc/D;AAED;;;GAGG;AACH,wBAAgB,gCAAgC,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAYzE;AAED,wBAAgB,oBAAoB,SAKnC"}
1
+ {"version":3,"file":"credentials.d.ts","sourceRoot":"","sources":["../src/credentials.ts"],"names":[],"mappings":"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"}
@@ -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 acquireCredentialsLock() {
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 timeoutMs = readLockDuration('ORIZU_CREDENTIALS_LOCK_TIMEOUT_MS', DEFAULT_CREDENTIALS_LOCK_TIMEOUT_MS, MAX_CREDENTIALS_LOCK_TIMEOUT_MS);
47
- const deadline = Date.now() + timeoutMs;
48
- while (true) {
49
- const metadata = JSON.stringify({
50
- version: 1,
51
- pid: process.pid,
52
- hostname: hostname(),
53
- createdAt: Date.now(),
54
- instanceId: randomBytes(16).toString('hex'),
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
- const fd = openSync(tempPath, 'wx', 0o600);
59
- try {
60
- writeFileSync(fd, metadata, 'utf8');
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
- // The unique owner-only preparation link is never retained, whether
87
- // publication succeeds, loses to an owner, or fails unexpectedly.
88
- rmSync(tempPath, { force: true });
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
- function releaseCredentialsLock(lock) {
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
- try {
138
- releaseCredentialsLock(lock);
139
- }
140
- finally {
141
- lock.resolveReleased();
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 = readLockDuration('ORIZU_CREDENTIALS_LOCK_TIMEOUT_MS', DEFAULT_CREDENTIALS_LOCK_TIMEOUT_MS, MAX_CREDENTIALS_LOCK_TIMEOUT_MS);
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 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.`);
176
- }
177
- return await withCredentialsTransactionLockAsync(operation);
211
+ if (!didRelease)
212
+ throw credentialsLockTimeoutError(timeoutMs);
213
+ return await withCredentialsTransactionLockAsync(operation, acquire);
178
214
  }
179
- const lock = acquireCredentialsLock();
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
- try {
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
- writeFileSync(fd, payload, 'utf-8');
250
- fsyncSync(fd);
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
- finally {
253
- closeSync(fd);
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
- return createEmptyCredentialsConfig();
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;AA6MxE,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,CA4BvB"}
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, withCredentialsTransactionLockAsync, } from './credentials.js';
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
- return await withCredentialsTransactionLockAsync(async () => {
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
- const localCredentialCleared = clearServerCredentialsIfCurrent(credentialKey, credentials);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orizu",
3
- "version": "0.5.50",
3
+ "version": "0.5.51",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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
- saveServerCredentials(baseUrl, {
157
- credentialType: 'pat',
158
- apiKey: loginData.apiKey,
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
- if (target.isDefault) {
162
- const detail = error instanceof Error ? error.message : String(error)
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
+ }
@@ -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 acquireCredentialsLock(): HeldCredentialsLock {
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 timeoutMs = readLockDuration(
91
- 'ORIZU_CREDENTIALS_LOCK_TIMEOUT_MS',
92
- DEFAULT_CREDENTIALS_LOCK_TIMEOUT_MS,
93
- MAX_CREDENTIALS_LOCK_TIMEOUT_MS
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
- while (true) {
98
- const metadata = JSON.stringify({
99
- version: 1,
100
- pid: process.pid,
101
- hostname: hostname(),
102
- createdAt: Date.now(),
103
- instanceId: randomBytes(16).toString('hex'),
104
- } satisfies CredentialsLockMetadata)
105
- const tempPath = join(
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
- const fd = openSync(tempPath, 'wx', 0o600)
112
- try {
113
- writeFileSync(fd, metadata, 'utf8')
114
- fsyncSync(fd)
115
- chmodSync(tempPath, 0o600)
116
- } finally {
117
- closeSync(fd)
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
- try {
121
- // link(2) publishes the already-complete inode without replacing an
122
- // existing owner. No contender ever unlinks the canonical lock.
123
- linkSync(tempPath, lockPath)
124
- } catch (error) {
125
- if (!(isNodeError(error) && error.code === 'EEXIST')) throw error
126
- if (Date.now() >= deadline) {
127
- throw new Error(
128
- `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.`
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
- let resolveReleased = () => {}
136
- const released = new Promise<void>(resolve => { resolveReleased = resolve })
137
- return { path: lockPath, metadata, depth: 1, released, resolveReleased }
138
- } finally {
139
- // The unique owner-only preparation link is never retained, whether
140
- // publication succeeds, loses to an owner, or fails unexpectedly.
141
- rmSync(tempPath, { force: true })
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
- try {
191
- releaseCredentialsLock(lock)
192
- } finally {
193
- lock.resolveReleased()
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 = readLockDuration(
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
- throw new Error(
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 = acquireCredentialsLock()
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
- try {
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
- writeFileSync(fd, payload, 'utf-8')
325
- fsyncSync(fd)
326
- } finally {
327
- closeSync(fd)
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
- chmodSync(tempPath, 0o600)
331
- // Never delete the destination to emulate replacement. If this platform
332
- // cannot atomically replace it, preserve the old file and surface the error.
333
- renameSync(tempPath, path)
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
- return createEmptyCredentialsConfig()
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
- return await withCredentialsTransactionLockAsync(async () => {
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
- const localCredentialCleared = clearServerCredentialsIfCurrent(credentialKey, credentials)
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
  }