gh-glance 0.15.0 → 0.15.1

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.
Files changed (3) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/index.mjs +151 -54
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,28 @@ All notable changes to this project are documented here.
5
5
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.15.1] - 2026-09-17
9
+
10
+ ### Fixed
11
+
12
+ - **A failed write no longer wedges every pane on the machine.** The shared
13
+ coordination lock is created with `open("wx")` and its owner record written
14
+ afterwards. When that write failed -- a full disk is the usual cause -- the
15
+ empty lock file stayed behind with no owner to test, so every pane, on every
16
+ request, reported "Coordinating with your other panes" until the file was
17
+ deleted by hand. A failed release (the rename can fail on a full APFS volume)
18
+ left a complete record naming a live owner that then waited on itself. Lock
19
+ creation now removes exactly what it created on any failure, a release that
20
+ cannot rename falls back to unlinking its own record, an acquirer that meets
21
+ its own record releases it, and an unreadable lock or recovery marker that has
22
+ gone untouched for ten seconds is quarantined through the same double-checked
23
+ protocol dead-owner locks already use -- which also clears the files 0.15.0
24
+ panes have already left behind.
25
+ - **`--doctor --probe` names why the governor is unavailable.** The API
26
+ governor line read `unavailable` for a lock nobody released and for a state
27
+ file that failed validation alike, and those are fixed in different places.
28
+ It now reads `unavailable (busy)`, `unavailable (corrupt)`, and so on.
29
+
8
30
  ## [0.15.0] - 2026-09-15
9
31
 
10
32
  ### Added
@@ -1120,7 +1142,8 @@ engineering, security, QA and UX. What follows is what changed as a result.
1120
1142
  - The `main` field from `package.json`. It advertised the file as importable,
1121
1143
  but importing it took over the terminal or exited the host process.
1122
1144
 
1123
- [Unreleased]: https://github.com/juan294/gh-glance/compare/v0.15.0...HEAD
1145
+ [Unreleased]: https://github.com/juan294/gh-glance/compare/v0.15.1...HEAD
1146
+ [0.15.1]: https://github.com/juan294/gh-glance/compare/v0.15.0...v0.15.1
1124
1147
  [0.15.0]: https://github.com/juan294/gh-glance/compare/v0.14.1...v0.15.0
1125
1148
  [0.14.1]: https://github.com/juan294/gh-glance/compare/v0.14.0...v0.14.1
1126
1149
  [0.14.0]: https://github.com/juan294/gh-glance/compare/v0.13.3...v0.14.0
package/index.mjs CHANGED
@@ -3545,6 +3545,14 @@ const GOVERNOR_MAX_LEASES = 128;
3545
3545
  const GOVERNOR_MAX_INTENTS = 512;
3546
3546
  const GOVERNOR_MAX_RESERVATIONS = 512;
3547
3547
  const GOVERNOR_LOCK_WAIT_MS = 250;
3548
+ // How old an unreadable lock record may be before it counts as abandoned. A
3549
+ // creator owns the path from open("wx") until its owner record is written, a
3550
+ // gap of microseconds; a record still unreadable this long after the file was
3551
+ // last touched was left by a creator whose write failed (a full disk) or that
3552
+ // died between the two calls. Without this bound such a file was immortal: no
3553
+ // PID to test, so dead-owner recovery never fired, and every pane on the
3554
+ // machine reported busy on every request until someone deleted it by hand.
3555
+ const GOVERNOR_LOCK_ORPHAN_MS = 10_000;
3548
3556
  const GOVERNOR_PROBE_DRAIN_MS = 30_000;
3549
3557
  const GOVERNOR_PUBLICATION_REINSPECT_MS = 1_000;
3550
3558
  const GOVERNOR_PROBE_TRANSITION_MS = 5_000;
@@ -4108,17 +4116,80 @@ function pidIsDead(pid, kill = process.kill.bind(process)) {
4108
4116
  }
4109
4117
  }
4110
4118
 
4119
+ // An unreadable lock record whose file has not been touched for
4120
+ // GOVERNOR_LOCK_ORPHAN_MS. Readable records are never orphans here, whatever
4121
+ // their age: a live owner may legitimately hold a lock for as long as its
4122
+ // critical section runs, and a dead one is the PID path's business.
4123
+ function lockRecordOrphaned(path, now = Date.now()) {
4124
+ try {
4125
+ return lockOwner(path) === null && statSync(path).mtimeMs + GOVERNOR_LOCK_ORPHAN_MS <= now;
4126
+ } catch {
4127
+ return false;
4128
+ }
4129
+ }
4130
+
4131
+ // Creates the lock file and its owner record as one step from every other
4132
+ // process's point of view: either the path holds a complete record naming this
4133
+ // owner, or it does not exist. open("wx") makes the path exist before the
4134
+ // record is written, and a failure in between -- a full disk, most often --
4135
+ // used to propagate straight past the release, leaving an empty file that no
4136
+ // PID check could ever reclaim. Every failure after open now removes the exact
4137
+ // inode this call created before reporting, so nothing outlives the attempt.
4138
+ function claimGovernorLock(lockPath, owner) {
4139
+ let descriptor;
4140
+ let created = null;
4141
+ try {
4142
+ descriptor = openSync(lockPath, "wx", 0o600);
4143
+ } catch (error) {
4144
+ return error?.code === "EEXIST" ? "held" : "failed";
4145
+ }
4146
+ try {
4147
+ created = fstatSync(descriptor).ino;
4148
+ writeFileSync(descriptor, JSON.stringify(owner), "utf8");
4149
+ closeSync(descriptor);
4150
+ descriptor = undefined;
4151
+ chmodSync(lockPath, 0o600);
4152
+ // A stalled creator can be reclaimed as an orphan while it sits between
4153
+ // open and write. Confirm the record on disk is still this one before the
4154
+ // caller enters its critical section, and take back a record that was
4155
+ // restored after the check below found it missing (see the own-record
4156
+ // branch in withGovernorLock for the rest of that recovery).
4157
+ return sameLockOwner(lockOwner(lockPath), owner) ? "acquired" : "held";
4158
+ } catch {
4159
+ if (descriptor !== undefined) {
4160
+ try { closeSync(descriptor); } catch { /* descriptor was already closed */ }
4161
+ }
4162
+ try {
4163
+ if (created !== null && statSync(lockPath).ino === created) unlinkSync(lockPath);
4164
+ } catch { /* already gone, or not the file this call created */ }
4165
+ return "failed";
4166
+ }
4167
+ }
4168
+
4111
4169
  function releaseGovernorLock(lockPath, nonce) {
4112
4170
  const owner = lockOwner(lockPath);
4113
4171
  if (!owner || owner.nonce !== nonce) return false;
4114
4172
  const releasePath = `${lockPath}.release-${nonce}`;
4115
4173
  try {
4116
4174
  renameSync(lockPath, releasePath);
4117
- unlinkSync(releasePath);
4118
- return true;
4119
4175
  } catch {
4120
- return false;
4176
+ // The rename-then-unlink order exists so the unlink can never hit a
4177
+ // successor's lock. A rename can still fail on its own -- a full APFS
4178
+ // volume needs fresh blocks for the directory update -- and giving up there
4179
+ // left a complete record naming a live owner that would wait on itself, and
4180
+ // every other pane on it, until that process exited. A lock still carrying
4181
+ // this nonce has no successor to protect, so the direct unlink is safe.
4182
+ try {
4183
+ if (lockOwner(lockPath)?.nonce !== nonce) return false;
4184
+ unlinkSync(lockPath);
4185
+ return true;
4186
+ } catch {
4187
+ return false;
4188
+ }
4121
4189
  }
4190
+ // The path is free from here on; the uniquely named leftover is private.
4191
+ try { unlinkSync(releasePath); } catch { /* exact private release file only */ }
4192
+ return true;
4122
4193
  }
4123
4194
 
4124
4195
  function governorRecoveryPaths(lockPath) {
@@ -4136,7 +4207,18 @@ function governorRecoveryActive(lockPath, kill) {
4136
4207
  let active = false;
4137
4208
  for (const recoveryPath of governorRecoveryPaths(lockPath)) {
4138
4209
  const owner = lockOwner(recoveryPath);
4139
- if (!owner || !pidIsDead(owner.pid, kill)) {
4210
+ if (!owner) {
4211
+ // A marker whose record never arrived. Marker names are unique, so an
4212
+ // unlink can only ever remove this one; the age bound is what separates a
4213
+ // recoverer mid-write from one whose write failed and left this behind.
4214
+ if (lockRecordOrphaned(recoveryPath)) {
4215
+ try { unlinkSync(recoveryPath); } catch { /* re-listed below */ }
4216
+ continue;
4217
+ }
4218
+ active = true;
4219
+ continue;
4220
+ }
4221
+ if (!pidIsDead(owner.pid, kill)) {
4140
4222
  active = true;
4141
4223
  continue;
4142
4224
  }
@@ -4153,29 +4235,35 @@ function observeGovernorArtifact(observer, kind, path) {
4153
4235
  try { observer?.(kind, path); } catch { /* test observation cannot affect locking */ }
4154
4236
  }
4155
4237
 
4156
- function quarantineDeadGovernorLock(lockPath, expectedOwner, {
4238
+ // Removes a lock nobody is going to release: either a complete record naming
4239
+ // a dead PID (expectedOwner), or -- with expectedOwner null -- an unreadable
4240
+ // record past GOVERNOR_LOCK_ORPHAN_MS, which is what a creator leaves when its
4241
+ // write fails or it dies between open and write. Both go through the same
4242
+ // recovery marker and the same re-check on both sides of the rename, so a
4243
+ // record that becomes valid at any point is restored rather than lost.
4244
+ function quarantineAbandonedGovernorLock(lockPath, expectedOwner, {
4157
4245
  pid = process.pid,
4158
4246
  kill = process.kill.bind(process),
4159
4247
  observeArtifact = null,
4160
4248
  } = {}) {
4161
4249
  const recoveryNonce = randomUUID();
4162
4250
  const recoveryPath = `${lockPath}.recovery-${recoveryNonce}`;
4163
- let descriptor;
4164
- try {
4165
- descriptor = openSync(recoveryPath, "wx", 0o600);
4166
- try {
4167
- writeFileSync(descriptor, JSON.stringify({ pid, nonce: recoveryNonce }), "utf8");
4168
- } finally {
4169
- closeSync(descriptor);
4170
- }
4171
- chmodSync(recoveryPath, 0o600);
4172
- observeGovernorArtifact(observeArtifact, "recovery", recoveryPath);
4173
- } catch (error) {
4174
- if (descriptor !== undefined) {
4175
- try { closeSync(descriptor); } catch { /* descriptor was already closed */ }
4176
- }
4177
- return error?.code === "EEXIST" ? "busy" : "failed";
4178
- }
4251
+ const claim = claimGovernorLock(recoveryPath, { pid, nonce: recoveryNonce });
4252
+ if (claim !== "acquired") return claim === "held" ? "busy" : "failed";
4253
+ observeGovernorArtifact(observeArtifact, "recovery", recoveryPath);
4254
+
4255
+ // The lock is still the abandoned one this call was asked to remove. For a
4256
+ // dead owner that is the same PID and nonce, confirmed dead again; for an
4257
+ // orphan it is a record that is still unreadable and still past the age
4258
+ // bound -- a creator that has since finished writing must be left alone.
4259
+ const stillAbandoned = (path) => expectedOwner === null
4260
+ ? lockRecordOrphaned(path)
4261
+ : sameLockOwner(lockOwner(path), expectedOwner) && pidIsDead(expectedOwner.pid, kill);
4262
+ // What the quarantined file must hold for the removal to be right: the same
4263
+ // dead record, or still no record at all. Anything else is a successor.
4264
+ const quarantinedAsExpected = (path) => expectedOwner === null
4265
+ ? lockOwner(path) === null
4266
+ : sameLockOwner(lockOwner(path), expectedOwner);
4179
4267
 
4180
4268
  const quarantinePath = `${lockPath}.quarantine-${randomUUID()}`;
4181
4269
  try {
@@ -4183,17 +4271,13 @@ function quarantineDeadGovernorLock(lockPath, expectedOwner, {
4183
4271
  // A killed recovery process leaves a uniquely named marker that another
4184
4272
  // process can remove only after its PID is confirmed dead; the path can
4185
4273
  // never be reused by a successor.
4186
- // Re-read both fields after taking that marker and again immediately before
4187
- // rename, so an owner that changed since the initial ESRCH result is never
4188
- // selected as the abandoned lock.
4189
- const confirmed = lockOwner(lockPath);
4190
- if (!sameLockOwner(confirmed, expectedOwner) || !pidIsDead(confirmed.pid, kill)) return "changed";
4191
- const beforeRename = lockOwner(lockPath);
4192
- if (!sameLockOwner(beforeRename, expectedOwner)) return "changed";
4274
+ // Re-read after taking that marker, immediately before the rename, so an
4275
+ // owner that changed since the initial check is never selected as the
4276
+ // abandoned lock.
4277
+ if (!stillAbandoned(lockPath)) return "changed";
4193
4278
  renameSync(lockPath, quarantinePath);
4194
4279
  observeGovernorArtifact(observeArtifact, "quarantine", quarantinePath);
4195
- const quarantined = lockOwner(quarantinePath);
4196
- if (!sameLockOwner(quarantined, expectedOwner)) {
4280
+ if (!quarantinedAsExpected(quarantinePath)) {
4197
4281
  // Acquisitions that raced the recovery marker cannot enter their critical
4198
4282
  // section. Restoring here preserves that successor rather than deleting
4199
4283
  // or leaving it under an abandoned quarantine name.
@@ -4241,19 +4325,15 @@ function withGovernorLock(scope, operation, {
4241
4325
  Atomics.wait(persistenceWaitCell, 0, 0, 5);
4242
4326
  return true;
4243
4327
  };
4328
+ const owner = { pid, nonce };
4244
4329
  for (;;) {
4245
4330
  if (governorRecoveryActive(lockPath, kill)) {
4246
4331
  if (!waitForLock()) return { ok: false, reason: "busy" };
4247
4332
  continue;
4248
4333
  }
4249
- try {
4250
- const descriptor = openSync(lockPath, "wx", 0o600);
4251
- try {
4252
- writeFileSync(descriptor, JSON.stringify({ pid, nonce }), "utf8");
4253
- } finally {
4254
- closeSync(descriptor);
4255
- }
4256
- chmodSync(lockPath, 0o600);
4334
+ const claim = claimGovernorLock(lockPath, owner);
4335
+ if (claim === "failed") return { ok: false, reason: "unwritable" };
4336
+ if (claim === "acquired") {
4257
4337
  observeGovernorArtifact(observeArtifact, "canonical", lockPath);
4258
4338
  if (governorRecoveryActive(lockPath, kill)) {
4259
4339
  releaseGovernorLock(lockPath, nonce);
@@ -4262,28 +4342,40 @@ function withGovernorLock(scope, operation, {
4262
4342
  }
4263
4343
  try {
4264
4344
  return operation();
4345
+ } catch {
4346
+ return { ok: false, reason: "unwritable" };
4265
4347
  } finally {
4266
4348
  releaseGovernorLock(lockPath, nonce);
4267
4349
  }
4268
- } catch (error) {
4269
- if (error?.code !== "EEXIST") return { ok: false, reason: "unwritable" };
4270
- const owner = lockOwner(lockPath);
4271
- // The creator owns the canonical path as soon as open("wx") succeeds,
4272
- // before its small JSON owner record is fully visible. Treat an unreadable
4273
- // record as owned during the bounded wait; stealing it could overlap the
4274
- // creator, while returning busy is always fail-closed.
4275
- if (!owner) {
4276
- if (!waitForLock()) return { ok: false, reason: "busy" };
4277
- continue;
4278
- }
4279
- if (pidIsDead(owner.pid, kill)) {
4280
- const recovery = quarantineDeadGovernorLock(lockPath, owner, { pid, kill, observeArtifact });
4350
+ }
4351
+ const holder = lockOwner(lockPath);
4352
+ if (sameLockOwner(holder, owner)) {
4353
+ // This call's own record, found on the path without having been
4354
+ // acquired: an orphan reclaim renamed it away while claimGovernorLock was
4355
+ // verifying, then restored it. Nobody else will ever release it.
4356
+ releaseGovernorLock(lockPath, nonce);
4357
+ continue;
4358
+ }
4359
+ // The creator owns the canonical path as soon as open("wx") succeeds,
4360
+ // before its small JSON owner record is fully visible. Treat an unreadable
4361
+ // record as owned during the bounded wait; stealing it could overlap the
4362
+ // creator, while returning busy is always fail-closed. Past the age bound
4363
+ // there is no creator left to overlap, and waiting would be forever.
4364
+ if (!holder) {
4365
+ if (lockRecordOrphaned(lockPath)) {
4366
+ const recovery = quarantineAbandonedGovernorLock(lockPath, null, { pid, kill, observeArtifact });
4281
4367
  if (recovery === "quarantined" || recovery === "changed") continue;
4282
- if (!waitForLock()) return { ok: false, reason: "busy" };
4283
- continue;
4284
4368
  }
4285
4369
  if (!waitForLock()) return { ok: false, reason: "busy" };
4370
+ continue;
4371
+ }
4372
+ if (pidIsDead(holder.pid, kill)) {
4373
+ const recovery = quarantineAbandonedGovernorLock(lockPath, holder, { pid, kill, observeArtifact });
4374
+ if (recovery === "quarantined" || recovery === "changed") continue;
4375
+ if (!waitForLock()) return { ok: false, reason: "busy" };
4376
+ continue;
4286
4377
  }
4378
+ if (!waitForLock()) return { ok: false, reason: "busy" };
4287
4379
  }
4288
4380
  }
4289
4381
 
@@ -5142,7 +5234,10 @@ function inspectGovernor(scope, nowMs) {
5142
5234
  }
5143
5235
 
5144
5236
  function governorHealth(result, nowMs = Date.now()) {
5145
- if (!result?.ok) return { status: "unavailable", leases: 0, resources: {} };
5237
+ // The doctor is the one surface that may name the raw reason: a bare
5238
+ // "unavailable" could not tell a lock nobody released (busy) from a file that
5239
+ // failed validation (corrupt), and those are fixed in different places.
5240
+ if (!result?.ok) return { status: `unavailable (${result?.reason ?? "unknown"})`, leases: 0, resources: {} };
5146
5241
  const state = result.value;
5147
5242
  let status = "healthy";
5148
5243
  if (RATE_RESOURCES.some((resource) => state.probeClaims[resource]?.leaseUntil > nowMs)) status = "waiting for probe";
@@ -17956,6 +18051,7 @@ export {
17956
18051
  GOVERNOR_MAX_LEASES,
17957
18052
  GOVERNOR_MAX_INTENTS,
17958
18053
  GOVERNOR_MAX_RESERVATIONS,
18054
+ GOVERNOR_LOCK_ORPHAN_MS,
17959
18055
  GOVERNOR_LOCK_WAIT_MS,
17960
18056
  GOVERNOR_PROBE_DRAIN_MS,
17961
18057
  GOVERNOR_ID_PATTERN,
@@ -17972,6 +18068,7 @@ export {
17972
18068
  readGovernorState,
17973
18069
  writeGovernorState,
17974
18070
  pidIsDead,
18071
+ claimGovernorLock,
17975
18072
  releaseGovernorLock,
17976
18073
  withGovernorLock,
17977
18074
  registerLease,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-glance",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "description": "A live-refreshing GitHub dashboard for your terminal: Actions, Issues, Pull Requests, and Security alerts in one narrow pane",
5
5
  "type": "module",
6
6
  "bin": {