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.
- package/CHANGELOG.md +24 -1
- package/index.mjs +151 -54
- 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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
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
|
|
4187
|
-
//
|
|
4188
|
-
//
|
|
4189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
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
|
-
}
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
//
|
|
4272
|
-
//
|
|
4273
|
-
//
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
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
|
-
|
|
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