@agfpd/iapeer 0.4.35 → 0.4.37
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/package.json +1 -1
- package/src/daemon/main.ts +19 -0
- package/src/lifecycle/index.ts +87 -5
- package/src/registry/index.ts +7 -0
- package/src/transport/index.ts +24 -2
package/package.json
CHANGED
package/src/daemon/main.ts
CHANGED
|
@@ -547,6 +547,25 @@ if (import.meta.main) {
|
|
|
547
547
|
}
|
|
548
548
|
process.on('SIGTERM', shutdown)
|
|
549
549
|
process.on('SIGINT', shutdown)
|
|
550
|
+
// В5 — a NON-signal crash (an unhandledRejection, or any uncaught error) would otherwise kill the
|
|
551
|
+
// daemon WITHOUT running handle.close() → failAll, so in-memory composer-queued messages (the sender
|
|
552
|
+
// already got queued:true) would vanish SILENTLY — the forbidden loss class. Run failAll on these
|
|
553
|
+
// paths too so senders are notified, then exit(1) so launchd KeepAlive respawns a clean daemon. A
|
|
554
|
+
// bounded timeout guarantees the exit even if close() hangs. (Hard SIGKILL/OOM cannot be caught — a
|
|
555
|
+
// durable on-disk composer queue would be needed for that, a deliberate follow-up.)
|
|
556
|
+
let crashing = false
|
|
557
|
+
const crashExit = (kind: string) => (errObj: unknown) => {
|
|
558
|
+
if (crashing) return
|
|
559
|
+
crashing = true
|
|
560
|
+
const detail = errObj instanceof Error ? (errObj.stack ?? errObj.message) : String(errObj)
|
|
561
|
+
process.stderr.write(`[iapeer-daemon] ${kind} — failing pending queued deliveries then exiting: ${detail}\n`)
|
|
562
|
+
const done = (): never => process.exit(1)
|
|
563
|
+
const t = setTimeout(done, 3000)
|
|
564
|
+
if (typeof t.unref === 'function') t.unref()
|
|
565
|
+
void handle.close().then(done, done)
|
|
566
|
+
}
|
|
567
|
+
process.on('uncaughtException', crashExit('uncaughtException'))
|
|
568
|
+
process.on('unhandledRejection', crashExit('unhandledRejection'))
|
|
550
569
|
await new Promise(() => {}) // launchd KeepAlive holds this process; block forever
|
|
551
570
|
}
|
|
552
571
|
}
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// before any wake or reap. wakeOrSpawn refuses a launchd peer; superviseTick and
|
|
13
13
|
// sweepZombies skip it. Only daemon-owned (no-plist) peers are managed here.
|
|
14
14
|
|
|
15
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'fs'
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'fs'
|
|
16
16
|
import { homedir } from 'os'
|
|
17
17
|
import { join } from 'path'
|
|
18
18
|
import { spawnSync } from 'child_process'
|
|
@@ -90,6 +90,10 @@ export interface LifecycleConfig {
|
|
|
90
90
|
* A still-WORKING silent-tool worker keeps the pane-log fresh via the TUI render, so it
|
|
91
91
|
* is no longer reaped mid-task — only a genuinely idle/wedged session crosses this. */
|
|
92
92
|
ephemeralUnarmedIdleSecs: number
|
|
93
|
+
/** Wake-lock acquisition retries (proper-lockfile). The budget is intentionally MODEST (a full
|
|
94
|
+
* boot can exceed it) — an exhausted budget surfaces as a RETRYABLE FAILED, not a raw ELOCKED, so
|
|
95
|
+
* the sender retries and routeSend logs the outcome. Tunable via IAPEER_WAKE_LOCK_RETRIES. */
|
|
96
|
+
wakeLockRetries: number
|
|
93
97
|
}
|
|
94
98
|
|
|
95
99
|
export function loadLifecycleConfig(env: NodeJS.ProcessEnv = process.env): LifecycleConfig {
|
|
@@ -113,6 +117,10 @@ export function loadLifecycleConfig(env: NodeJS.ProcessEnv = process.env): Lifec
|
|
|
113
117
|
crashLoopWindowSecs: num(env.IAPEER_CRASHLOOP_WINDOW_SECS, 300),
|
|
114
118
|
ephemeralQuietSecs: num(env.IAPEER_EPHEMERAL_QUIET_SECS, 20),
|
|
115
119
|
ephemeralUnarmedIdleSecs: num(env.IAPEER_EPHEMERAL_UNARMED_IDLE_SECS, 600),
|
|
120
|
+
wakeLockRetries: (() => {
|
|
121
|
+
const n = parseInt(env.IAPEER_WAKE_LOCK_RETRIES ?? '', 10)
|
|
122
|
+
return Number.isFinite(n) && n >= 0 ? n : 30
|
|
123
|
+
})(),
|
|
116
124
|
}
|
|
117
125
|
}
|
|
118
126
|
|
|
@@ -171,6 +179,22 @@ export function removeSessionState(cfg: LifecycleConfig, identity: string): void
|
|
|
171
179
|
}
|
|
172
180
|
}
|
|
173
181
|
|
|
182
|
+
/** Atomically CLAIM a dead session for reaping: rename `.session` → `.session.reaping`. Returns true
|
|
183
|
+
* iff THIS caller won the rename. superviseTick runs from BOTH the daemon timer AND the prelude of
|
|
184
|
+
* every wakeOrSpawn (in whatever process called it), so two passes can see the same dead `.session`
|
|
185
|
+
* in the same sub-second window; without a claim BOTH would recordDeath, and TWO deaths for ONE real
|
|
186
|
+
* death can trip the crash-loop guard prematurely (a peer refused to wake for the whole window). The
|
|
187
|
+
* rename is atomic on a single filesystem — the loser gets ENOENT → false and skips recordDeath/eager.
|
|
188
|
+
* A leftover `.reaping` (crash mid-reap) is ignored by readSessionStates (it filters `.session`). */
|
|
189
|
+
function claimDeadSession(cfg: LifecycleConfig, identity: string): boolean {
|
|
190
|
+
try {
|
|
191
|
+
renameSync(sessionStatePath(cfg, identity), `${sessionStatePath(cfg, identity)}.reaping`)
|
|
192
|
+
return true
|
|
193
|
+
} catch {
|
|
194
|
+
return false // ENOENT — another pass already claimed this death (or the file vanished)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
174
198
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
175
199
|
// C1 — durable stopped flag (warm-on-demand stop/start; contract ЖЦ §stop/start,
|
|
176
200
|
// Демон §stopped). `stop <peer>` on a warm runtime kills the session AND drops this
|
|
@@ -702,7 +726,13 @@ export async function withWakeLock<T>(
|
|
|
702
726
|
realpath: false,
|
|
703
727
|
stale: 60_000,
|
|
704
728
|
update: 5_000,
|
|
705
|
-
retries: { retries:
|
|
729
|
+
retries: { retries: cfg.wakeLockRetries, factor: 1.3, minTimeout: 100, maxTimeout: 1_000 },
|
|
730
|
+
// В5 — a compromised wake-lock (mtime-refresh timer fired late, e.g. after system sleep >60s) MUST
|
|
731
|
+
// NOT rethrow from proper-lockfile's timer: unhandled it crashes the whole router and silently drops
|
|
732
|
+
// the in-memory composer queue. Log and continue — a controlled degrade beats killing the daemon.
|
|
733
|
+
onCompromised: (err: Error) => {
|
|
734
|
+
process.stderr.write(`[iapeer] WARN wake-lock compromised for ${identity} (continuing, not crashing): ${err.message}\n`)
|
|
735
|
+
},
|
|
706
736
|
})
|
|
707
737
|
try {
|
|
708
738
|
return await fn()
|
|
@@ -953,7 +983,7 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
|
|
|
953
983
|
}
|
|
954
984
|
}
|
|
955
985
|
|
|
956
|
-
|
|
986
|
+
const runWake = async (): Promise<WakeResult> => {
|
|
957
987
|
// Re-check the refusal gates INSIDE the lock (audit #3/#11): a `stop` (C1 flag) or a
|
|
958
988
|
// plist install that completed AFTER the pre-lock check but before the lock was
|
|
959
989
|
// acquired must still be honored — else a concurrently stopped / launchd-claimed peer
|
|
@@ -1074,6 +1104,14 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
|
|
|
1074
1104
|
try {
|
|
1075
1105
|
writeSessionState(cfg, { identity, runtime, personality: args.personality, cwd, wokeAt: Date.now() })
|
|
1076
1106
|
} catch { /* keep the original FAILED reason — enroll is best-effort */ }
|
|
1107
|
+
} else {
|
|
1108
|
+
// В14 — the launch FAILED and left NO live remnant → a genuine boot-crash (broken binary /
|
|
1109
|
+
// doctrine, process died before READY). It writes NO .session, so superviseTick never sees it
|
|
1110
|
+
// and never records the death → countRecentDeaths stays 0 and the crash-loop guard NEVER fires.
|
|
1111
|
+
// Every next message / ephemeral-drain then re-runs the doomed launch (up to bootDeadline each)
|
|
1112
|
+
// forever. Count the fault here so the guard bounds the loop after crashLoopMax in the window.
|
|
1113
|
+
// (No claim needed — there is no .session for a concurrent pass to race on.)
|
|
1114
|
+
recordDeath(cfg, identity, Date.now())
|
|
1077
1115
|
}
|
|
1078
1116
|
return { status: 'FAILED', woke: false, runtime, reason: result.reason }
|
|
1079
1117
|
}
|
|
@@ -1102,7 +1140,29 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
|
|
|
1102
1140
|
addTopic(cfg, identity, args.topic?.trim() ?? '')
|
|
1103
1141
|
trimDeaths(cfg, identity, cfg.crashLoopWindowSecs, Date.now())
|
|
1104
1142
|
return { status: 'READY', woke: true, runtime, process_address: identity, taskDelivered: true }
|
|
1105
|
-
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
try {
|
|
1146
|
+
return await withWakeLock(cfg, identity, runWake)
|
|
1147
|
+
} catch (e) {
|
|
1148
|
+
// В15 — the wake-lock retry budget (~25s) is shorter than a legitimate boot (bootDeadline +
|
|
1149
|
+
// readyGate, up to minutes). A concurrent send to a WAKING peer can exhaust it → proper-lockfile
|
|
1150
|
+
// throws ELOCKED, which otherwise escaped through routeSend as a raw protocol error with NO
|
|
1151
|
+
// delivery.log line (a hole in the very log that reconstructs a suspected loss). Convert it to a
|
|
1152
|
+
// RETRYABLE FAILED: the sender retries, the fast path then finds the now-live session, and routeSend
|
|
1153
|
+
// records the outcome. re-resolving each retry = verify-before-act (no false-OK).
|
|
1154
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
1155
|
+
if ((e as NodeJS.ErrnoException).code === 'ELOCKED' || /\bELOCKED\b|lock.*already.*held/i.test(msg)) {
|
|
1156
|
+
logWake({ identity, runtime, mode: 'refused', cause: 'wake-lock-held' })
|
|
1157
|
+
return {
|
|
1158
|
+
status: 'FAILED',
|
|
1159
|
+
woke: false,
|
|
1160
|
+
runtime,
|
|
1161
|
+
reason: `"${args.personality}" (${runtime}) wake already in progress (another wake holds the lock) — retry shortly`,
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
throw e
|
|
1165
|
+
}
|
|
1106
1166
|
}
|
|
1107
1167
|
|
|
1108
1168
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1200,8 +1260,15 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
|
|
|
1200
1260
|
// A dead session: record a death for crash-loop accounting, then branch on the
|
|
1201
1261
|
// .new-eager mark. This death was NOT daemon-initiated (the daemon only initiates
|
|
1202
1262
|
// the idle-reap below) → it died on its own → do NOT write .idle-reaped here.
|
|
1263
|
+
// ATOMIC CLAIM (В16): a concurrent supervise pass (daemon timer + a CLI-process
|
|
1264
|
+
// wakeOrSpawn prelude) must not BOTH recordDeath — two deaths for one real death can
|
|
1265
|
+
// trip the crash-loop guard prematurely. The rename-claim makes recordDeath + eager
|
|
1266
|
+
// happen exactly once; the loser reports a benign concurrently-reaped outcome.
|
|
1267
|
+
if (!claimDeadSession(cfg, s.identity)) {
|
|
1268
|
+
return { identity: s.identity, action: 'reaped-gone', reason: 'concurrently reaped by another supervise pass' }
|
|
1269
|
+
}
|
|
1203
1270
|
recordDeath(cfg, s.identity, nowMs)
|
|
1204
|
-
|
|
1271
|
+
rmSync(`${sessionStatePath(cfg, s.identity)}.reaping`, { force: true }) // claim token consumed
|
|
1205
1272
|
// The .ephemeral-armed mark belongs to THIS (now dead) session — it armed on its
|
|
1206
1273
|
// outbound reply. Clear it with the session, so a successor session can never be
|
|
1207
1274
|
// quiet-reaped on a stale mark before answering its own task. No-op otherwise.
|
|
@@ -1229,6 +1296,21 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
|
|
|
1229
1296
|
trace({ identity: s.identity, action: 'reaped-gone', death: gone.death, reason: gone.reason, outcome: 'fresh-next-msg' })
|
|
1230
1297
|
return { identity: s.identity, action: 'reaped-gone', reason: gone.reason }
|
|
1231
1298
|
}
|
|
1299
|
+
// В17 — a LIVE session carrying the .stopped flag is an illegitimate state: `stop` takes no wake-lock,
|
|
1300
|
+
// so a stop that raced an in-flight wake killed the pre-pidfile session as a no-op and the wake then
|
|
1301
|
+
// drove a NEW session to READY → alive + .stopped. The live path (routeSend) does not re-check
|
|
1302
|
+
// .stopped, so this peer keeps accepting messages until idle-reap (up to 1h) — the operator halt
|
|
1303
|
+
// silently failed. Reap it now (catch-up for the window the wake-lock cannot cover). H4 is already
|
|
1304
|
+
// cleared above; no legitimate alive+stopped state exists. Park like the stop verb (.idle-reaped is
|
|
1305
|
+
// idempotent with it) — .stopped keeps the peer down until `start`, .idle-reaped makes it resume-clean.
|
|
1306
|
+
if (isStopped(cfg, s.identity)) {
|
|
1307
|
+
kill(sock, s.identity)
|
|
1308
|
+
setIdleReaped(cfg, s.identity)
|
|
1309
|
+
clearEphemeralArmed(cfg, s.identity)
|
|
1310
|
+
removeSessionState(cfg, s.identity)
|
|
1311
|
+
trace({ identity: s.identity, action: 'reaped-idle', reason: 'alive-but-stopped', outcome: 'operator-halt-enforced' })
|
|
1312
|
+
return { identity: s.identity, action: 'reaped-idle', reason: 'live session carried .stopped — operator halt enforced (stop-vs-wake race)' }
|
|
1313
|
+
}
|
|
1232
1314
|
// Idle accounting via the LAST MEANINGFUL TRANSCRIPT ENTRY's content-timestamp (adapter.lastTurnMtime)
|
|
1233
1315
|
// — the only RELIABLE idle signal. The two raw mtimes both report FALSE freshness for an idle session:
|
|
1234
1316
|
// • pane-log mtime — a statusline / footer re-render writes pty bytes at idle (the clock/usage/ctx%
|
package/src/registry/index.ts
CHANGED
|
@@ -297,6 +297,13 @@ export async function withPeersLock<T>(
|
|
|
297
297
|
stale: 10_000,
|
|
298
298
|
update: 1_000,
|
|
299
299
|
retries: { retries: 13, factor: 1.4, minTimeout: 50, maxTimeout: 500 },
|
|
300
|
+
// В5 — a compromised lock (e.g. the mtime-refresh timer fired late after system sleep, so the lock
|
|
301
|
+
// read as stale) MUST NOT rethrow from proper-lockfile's timer: unhandled, it would crash the whole
|
|
302
|
+
// router process and silently drop the in-memory composer queue. Log loudly and let the current
|
|
303
|
+
// (near-done) critical section finish — a controlled degrade beats taking the daemon down.
|
|
304
|
+
onCompromised: (err: Error) => {
|
|
305
|
+
process.stderr.write(`[iapeer] WARN peers-lock compromised (continuing, not crashing): ${err.message}\n`)
|
|
306
|
+
},
|
|
300
307
|
})
|
|
301
308
|
try {
|
|
302
309
|
return await fn(paths)
|
package/src/transport/index.ts
CHANGED
|
@@ -1025,7 +1025,17 @@ export function createComposerDeliveryQueue(opts: ComposerDeliveryQueueOptions):
|
|
|
1025
1025
|
function kick(identity: string): void {
|
|
1026
1026
|
if (draining.has(identity)) return
|
|
1027
1027
|
draining.add(identity)
|
|
1028
|
-
void drain(identity)
|
|
1028
|
+
void drain(identity)
|
|
1029
|
+
.catch(() => {
|
|
1030
|
+
/* drain is internally best-effort; a stray throw must not surface as an unhandled rejection */
|
|
1031
|
+
})
|
|
1032
|
+
.finally(() => {
|
|
1033
|
+
draining.delete(identity)
|
|
1034
|
+
// В9 — close the enqueue-during-teardown race: a job pushed AFTER drain's empty-return but
|
|
1035
|
+
// BEFORE this finally cleared `draining` saw kick() no-op (draining still held), leaving it
|
|
1036
|
+
// undrained until some unrelated later enqueue. Re-kick if work remains so it drains promptly.
|
|
1037
|
+
if (queues.get(identity)?.length) kick(identity)
|
|
1038
|
+
})
|
|
1029
1039
|
}
|
|
1030
1040
|
|
|
1031
1041
|
async function drain(identity: string): Promise<void> {
|
|
@@ -1050,8 +1060,20 @@ export function createComposerDeliveryQueue(opts: ComposerDeliveryQueueOptions):
|
|
|
1050
1060
|
continue
|
|
1051
1061
|
}
|
|
1052
1062
|
|
|
1053
|
-
|
|
1063
|
+
// В8 — take the head OUT of the queue BEFORE committing to deliver. A concurrent failAll snapshots
|
|
1064
|
+
// only what remains queued, so an IN-FLIGHT job is never both delivered AND failed (the dup / false-
|
|
1065
|
+
// failure the exactly-once contract forbids). The head stayed queued during the composer poll-wait
|
|
1066
|
+
// above (still undelivered → failAll correctly fails it); it leaves the queue only at this commit.
|
|
1054
1067
|
q.shift()
|
|
1068
|
+
let delivered: Result<void>
|
|
1069
|
+
try {
|
|
1070
|
+
delivered = await deliver(job.target, job.envelope, job.peer.cwd)
|
|
1071
|
+
} catch (e) {
|
|
1072
|
+
// deliverWarm normally returns a Result, but a defensive catch keeps drain from throwing (which,
|
|
1073
|
+
// with the В9 re-kick, could otherwise spin) — an unexpected throw is a delivery failure.
|
|
1074
|
+
await notifyFailed(job, `queued delivery to ${job.target.personality} (${job.target.runtime}) failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
1075
|
+
continue
|
|
1076
|
+
}
|
|
1055
1077
|
if (!delivered.ok) {
|
|
1056
1078
|
await notifyFailed(job, `queued delivery to ${job.target.personality} (${job.target.runtime}) failed: ${delivered.error.message}`)
|
|
1057
1079
|
} else {
|