@agfpd/iapeer 0.4.48 → 0.4.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/supervisor/client.ts +5 -2
- package/src/supervisor/index.ts +54 -4
- package/src/supervisor/paths.ts +27 -2
- package/src/tui/dashboard/model.ts +11 -0
- package/src/tui/dashboard/run.tsx +26 -18
package/package.json
CHANGED
package/src/supervisor/client.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// via writeSync which works inside a signal handler), and #2 backpressure is the daemon's side.
|
|
5
5
|
import { writeSync } from 'node:fs'
|
|
6
6
|
import { FRAME_DATA, FRAME_RESIZE, TERM_RESET, frame, makeFramer, sizePayload } from './protocol.ts'
|
|
7
|
-
import { pidStartToken, readPidFile, sockPath } from './paths.ts'
|
|
7
|
+
import { ownershipVerdict, pidStartToken, readPidFile, sockPath } from './paths.ts'
|
|
8
8
|
|
|
9
9
|
const DETACH = 0x1d // Ctrl-]
|
|
10
10
|
|
|
@@ -32,8 +32,11 @@ export function sessionAlive(runDir: string, session: string): boolean {
|
|
|
32
32
|
const now = Date.now()
|
|
33
33
|
const cached = ownerCache.get(key)
|
|
34
34
|
if (cached && cached.pid === rec.pid && cached.token === rec.token && now < cached.until) return cached.verdict
|
|
35
|
+
// Fail-SAFE on a token-READ failure (split-brain live incident 03.07): a transient `ps` spawn error
|
|
36
|
+
// returns null; treating that as "dead" let one hiccup reap a LIVE session and spawn a duplicate per
|
|
37
|
+
// message. kill-0 above already proved the pid live — only a PROVEN mismatch (a reused pid) reads dead.
|
|
35
38
|
const live = pidStartToken(rec.pid)
|
|
36
|
-
const verdict = live
|
|
39
|
+
const verdict = ownershipVerdict(live, rec.token) // same process instance unless proven otherwise
|
|
37
40
|
ownerCache.set(key, { pid: rec.pid, token: rec.token, verdict, until: now + OWNER_TTL_MS })
|
|
38
41
|
return verdict
|
|
39
42
|
}
|
package/src/supervisor/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { spawn as nodeSpawn } from 'node:child_process'
|
|
|
8
8
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
9
9
|
import { fileURLToPath } from 'node:url'
|
|
10
10
|
import { runSupervisorClient, sessionAlive } from './client.ts'
|
|
11
|
-
import { defaultRunDir, logPath, pidPath, pidStartToken, readPidFile, servePath, sockPath } from './paths.ts'
|
|
11
|
+
import { defaultRunDir, logPath, ownershipVerdict, pidPath, pidStartToken, readPidFile, servePath, sockPath } from './paths.ts'
|
|
12
12
|
|
|
13
13
|
// daemon.ts loads @xterm. It is dynamic-imported ONLY in the 'daemon' CLI case below (the detached
|
|
14
14
|
// daemon process, where @xterm belongs), so importing THIS module — e.g. launch resolving
|
|
@@ -66,6 +66,46 @@ export interface StartDaemonResult {
|
|
|
66
66
|
detail?: string
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/** Does a unix socket at `path` ACCEPT connections right now? A dead leftover sock file refuses
|
|
70
|
+
* instantly (ECONNREFUSED → false); the timeout only bounds a pathologically wedged listener. */
|
|
71
|
+
async function sockAccepts(path: string, timeoutMs = 400): Promise<boolean> {
|
|
72
|
+
return await new Promise<boolean>(resolve => {
|
|
73
|
+
let done = false
|
|
74
|
+
const finish = (v: boolean): void => {
|
|
75
|
+
if (done) return
|
|
76
|
+
done = true
|
|
77
|
+
clearTimeout(timer)
|
|
78
|
+
resolve(v)
|
|
79
|
+
}
|
|
80
|
+
const timer = setTimeout(() => finish(false), timeoutMs)
|
|
81
|
+
Bun.connect({
|
|
82
|
+
unix: path,
|
|
83
|
+
socket: {
|
|
84
|
+
open(s) {
|
|
85
|
+
// finish BEFORE end(): under Bun, end() re-enters close() SYNCHRONOUSLY, and a
|
|
86
|
+
// close()-first would read an accepting listener as dead (verified by probe).
|
|
87
|
+
finish(true)
|
|
88
|
+
try {
|
|
89
|
+
s.end()
|
|
90
|
+
} catch {
|
|
91
|
+
/* probe only */
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
data() {},
|
|
95
|
+
close() {
|
|
96
|
+
finish(false) // closed before open fired → not accepting
|
|
97
|
+
},
|
|
98
|
+
error() {
|
|
99
|
+
finish(false)
|
|
100
|
+
},
|
|
101
|
+
connectError() {
|
|
102
|
+
finish(false) // dead leftover sock file → connect refused
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
}).catch(() => finish(false))
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
69
109
|
/**
|
|
70
110
|
* Re-invoke THIS module as the detached daemon (spawn-flip Ф1 serving-wiring). From SOURCE (bun) the
|
|
71
111
|
* module is a real file on disk → run it directly (`bun <self> daemon …` → import.meta.main →
|
|
@@ -93,6 +133,15 @@ export async function startSupervisorDaemon(opts: StartDaemonOptions): Promise<S
|
|
|
93
133
|
const { session, runtime, runDir } = opts
|
|
94
134
|
if (sessionAlive(runDir, session)) return { state: 'already-running' }
|
|
95
135
|
mkdirSync(runDir, { recursive: true })
|
|
136
|
+
// Second-instance guard (В28-class, peer-session form — split-brain live incident 03.07): NEVER unlink
|
|
137
|
+
// a sock that still ACCEPTS connections. sessionAlive above keys on the pidfile, which a transient
|
|
138
|
+
// token-read failure or a clobbered pidfile can falsify — but a live supervisor HOLDS its unix socket
|
|
139
|
+
// open, so a successful connect is proof a session exists. Spawning here would create a duplicate
|
|
140
|
+
// session (split-brain: two agents answering as one peer) and orphan the live one by overwriting its
|
|
141
|
+
// pidfile. The probe client sends no FRAME_RESIZE, so it never counts as an attached operator (В23).
|
|
142
|
+
if (existsSync(sockPath(runDir, session)) && (await sockAccepts(sockPath(runDir, session)))) {
|
|
143
|
+
return { state: 'already-running' }
|
|
144
|
+
}
|
|
96
145
|
try {
|
|
97
146
|
if (existsSync(sockPath(runDir, session))) unlinkSync(sockPath(runDir, session))
|
|
98
147
|
} catch {
|
|
@@ -174,9 +223,10 @@ export function killSession(runDir: string, session: string): boolean {
|
|
|
174
223
|
// В22 — verify the pid is STILL our daemon before SIGTERM. After an abnormal death + pid reuse, a bare
|
|
175
224
|
// kill here would SIGTERM the INNOCENT process that inherited the pid. Skip the kill when the start-token
|
|
176
225
|
// no longer matches (a legacy tokenless pidfile keeps the old behavior — no regression during rollout).
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
226
|
+
// Same ownershipVerdict primitive as sessionAlive: refuse ONLY on a PROVEN mismatch (null = unreadable
|
|
227
|
+
// token, still ours — kill must stay possible when `ps` hiccups, or a stuck session becomes unstoppable).
|
|
228
|
+
if (rec.token && !ownershipVerdict(pidStartToken(rec.pid), rec.token)) {
|
|
229
|
+
return false // reused pid — NOT our session; do not signal it
|
|
180
230
|
}
|
|
181
231
|
try {
|
|
182
232
|
process.kill(rec.pid, 'SIGTERM')
|
package/src/supervisor/paths.ts
CHANGED
|
@@ -24,11 +24,22 @@ export const pidPath = (runDir: string, session: string): string => join(runDir,
|
|
|
24
24
|
|
|
25
25
|
/** The OS-reported start time of `pid` (a stable per-process-instance token), or null if it is not
|
|
26
26
|
* running / unreadable. `ps -o lstart=` is a single spawn — callers bound its frequency (see the
|
|
27
|
-
* liveness cache in client.ts).
|
|
27
|
+
* liveness cache in client.ts).
|
|
28
|
+
*
|
|
29
|
+
* LC_ALL=C is PINNED (split-brain live incident 03.07): `lstart` output is LOCALE-DEPENDENT, and the
|
|
30
|
+
* token is compared as a STRING between processes with different env — the writer (a supervisor
|
|
31
|
+
* inheriting the spawning terminal's locale, e.g. an operator's ru_RU `iapeer attach`) rendered
|
|
32
|
+
* «пятница, 3 июля …» while the verifying daemon (launchd env, C locale) rendered "Fri Jul 3 …"
|
|
33
|
+
* for the SAME start time → a PROVEN-looking mismatch → the live session read as a reused pid,
|
|
34
|
+
* got reaped, and the next message spawned a duplicate. Pinning the locale on BOTH the write and
|
|
35
|
+
* the verify path makes the token format process-env-independent. */
|
|
28
36
|
export function pidStartToken(pid: number): string | null {
|
|
29
37
|
if (!Number.isInteger(pid) || pid <= 0) return null
|
|
30
38
|
try {
|
|
31
|
-
const r = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
|
|
39
|
+
const r = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
|
|
40
|
+
encoding: 'utf8',
|
|
41
|
+
env: { ...process.env, LC_ALL: 'C' },
|
|
42
|
+
})
|
|
32
43
|
const t = (r.stdout ?? '').trim()
|
|
33
44
|
return t.length > 0 ? t : null
|
|
34
45
|
} catch {
|
|
@@ -36,6 +47,20 @@ export function pidStartToken(pid: number): string | null {
|
|
|
36
47
|
}
|
|
37
48
|
}
|
|
38
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Ownership verdict from a live-read token vs the recorded one — the ONE semantics both liveness
|
|
52
|
+
* (`sessionAlive`) and kill (`killSession`) key on: the pid is ours unless a PROVEN mismatch.
|
|
53
|
+
* `live === null` means the token could not be READ (a transient `ps` spawn failure — EAGAIN under a
|
|
54
|
+
* post-reboot fork-storm, live incident 03.07: split-brain boris), NOT that the pid was reused; kill-0
|
|
55
|
+
* has already proven a live pid, so an unverifiable token must stay fail-SAFE (alive / still ours).
|
|
56
|
+
* Before this primitive, sessionAlive required `live !== null && live === token` (fail-open: a tool
|
|
57
|
+
* hiccup read a LIVE supervisor as dead → every wake spawned a duplicate session and orphaned the
|
|
58
|
+
* previous one by overwriting the pidfile), while killSession already treated null as "ours".
|
|
59
|
+
*/
|
|
60
|
+
export function ownershipVerdict(live: string | null, recorded: string): boolean {
|
|
61
|
+
return live === null ? true : live === recorded
|
|
62
|
+
}
|
|
63
|
+
|
|
39
64
|
/** Write the pidfile as `<pid> <startToken>` (В22). Falls back to bare `<pid>` if the token is
|
|
40
65
|
* unavailable — a tokenless pidfile is treated as legacy (owner-check skipped, kill-0 only). */
|
|
41
66
|
export function writePidFile(runDir: string, session: string, pid: number): void {
|
|
@@ -125,3 +125,14 @@ export function ellipsize(s: string, width: number): string {
|
|
|
125
125
|
const chars = [...s]
|
|
126
126
|
return chars.length <= width ? s : chars.slice(0, Math.max(0, width - 1)).join('') + '…'
|
|
127
127
|
}
|
|
128
|
+
|
|
129
|
+
// ─── attach-child failure surfacing ─────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/** Why a spawned `iapeer attach <peer>` run needs the operator's attention, or null when it
|
|
132
|
+
* ended fine. PURE: a spawn error AND a non-zero/signal exit both surface — a failed child
|
|
133
|
+
* silently remounted over reads as "Enter does nothing" to the operator (live incident 03.07). */
|
|
134
|
+
export function attachFailureMessage(r: { error?: Error; status: number | null }): string | null {
|
|
135
|
+
if (r.error) return r.error.message
|
|
136
|
+
if ((r.status ?? 1) !== 0) return `iapeer attach exited with ${r.status ?? 'a signal'}`
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
// - alt-screen: the dashboard is a full-screen live surface; it renders on the
|
|
5
5
|
// alternate screen so quitting restores the operator's scrollback untouched
|
|
6
6
|
// (Apple Terminal keeps no E3, so repeated in-scrollback repaints would pile up —
|
|
7
|
-
// the alt-screen sidesteps that class entirely).
|
|
7
|
+
// the alt-screen sidesteps that class entirely). The alt-screen is owned by INK
|
|
8
|
+
// (`alternateScreen: true`), NOT written manually: Ink's resize/erase heuristics
|
|
9
|
+
// (clear-on-width-shrink, fullscreen-frame clears) key on the buffer IT manages —
|
|
10
|
+
// a manual \x1b[?1049h behind Ink's back left it erasing by stale line-counts, so
|
|
11
|
+
// a window resize stacked old frames (live incident: the footer repeated ~5×).
|
|
8
12
|
// - интерактив только в реальном TTY (контракт фазы): stdin+stdout must both be
|
|
9
13
|
// TTYs, else the caller falls back to the scriptable table (never render + hang;
|
|
10
14
|
// the same fail-closed belt as the onboard wizard).
|
|
@@ -12,7 +16,10 @@
|
|
|
12
16
|
// dashboard fully unmounts + leaves the alt-screen + returns the terminal to
|
|
13
17
|
// cooked mode, then spawns `iapeer attach <peer>` as a CHILD with inherited stdio
|
|
14
18
|
// (the runtime TUI gets the REAL tty — the readline-class constraint), waits, and
|
|
15
|
-
// remounts. One full-screen TUI owns the terminal at any moment.
|
|
19
|
+
// remounts. One full-screen TUI owns the terminal at any moment. A child that
|
|
20
|
+
// FAILS (spawn error OR exit≠0) is surfaced and acknowledged — never silently
|
|
21
|
+
// remounted over (live incident: Enter looked dead because the attach child died
|
|
22
|
+
// instantly and the remount swallowed its error).
|
|
16
23
|
//
|
|
17
24
|
// Self-invocation uses the same `$bunfs` discriminator as supervisor's
|
|
18
25
|
// resolveDaemonSelfArgv: compiled standalone → re-invoke the binary by verb; running
|
|
@@ -23,13 +30,11 @@ import { render } from 'ink'
|
|
|
23
30
|
import { spawnSync } from 'node:child_process'
|
|
24
31
|
import { fileURLToPath } from 'node:url'
|
|
25
32
|
import { DashboardApp, type DashAction } from './app.tsx'
|
|
33
|
+
import { attachFailureMessage } from './model.ts'
|
|
26
34
|
|
|
27
35
|
/** Sentinel: no real interactive TTY — the caller should print the scriptable table. */
|
|
28
36
|
export const DASHBOARD_NOT_INTERACTIVE = -1
|
|
29
37
|
|
|
30
|
-
const ALT_SCREEN_ON = '\x1b[?1049h\x1b[H'
|
|
31
|
-
const ALT_SCREEN_OFF = '\x1b[?1049l'
|
|
32
|
-
|
|
33
38
|
/** argv prefix that re-invokes THIS package's CLI (compiled binary by verb / src via
|
|
34
39
|
* bun). Same discriminator as supervisor/index.ts resolveDaemonSelfArgv: `$bunfs` in
|
|
35
40
|
* the module path marks the compiled standalone (existsSync is NO discriminator —
|
|
@@ -49,17 +54,16 @@ export interface DashboardOptions {
|
|
|
49
54
|
/** Mount the dashboard once; resolve with the action that ended it. */
|
|
50
55
|
async function mountOnce(env: NodeJS.ProcessEnv): Promise<DashAction> {
|
|
51
56
|
let action: DashAction = { type: 'quit' }
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
}
|
|
57
|
+
const app = render(<DashboardApp env={env} onAction={a => (action = a)} />, {
|
|
58
|
+
// the dashboard repaints in place on the alt screen; Ctrl-C is handled by the
|
|
59
|
+
// app itself (quit action) so a half-torn frame never leaks to the main screen
|
|
60
|
+
exitOnCtrlC: false,
|
|
61
|
+
// Ink OWNS the alt-screen (enter on mount, exit+cursor-restore on unmount —
|
|
62
|
+
// BEFORE waitUntilExit resolves, so the attach child always gets the primary
|
|
63
|
+
// buffer). Manual 1049-writes behind Ink's back broke its resize erase model.
|
|
64
|
+
alternateScreen: true,
|
|
65
|
+
})
|
|
66
|
+
await app.waitUntilExit()
|
|
63
67
|
return action
|
|
64
68
|
}
|
|
65
69
|
|
|
@@ -87,8 +91,12 @@ export async function runDashboard(opts: DashboardOptions = {}): Promise<number>
|
|
|
87
91
|
stdio: 'inherit',
|
|
88
92
|
env,
|
|
89
93
|
})
|
|
90
|
-
|
|
91
|
-
|
|
94
|
+
// Surface BOTH failure shapes before remounting: a spawn error AND a child that ran but
|
|
95
|
+
// FAILED (exit≠0 / signal — its own stderr is already on the primary screen via inherit).
|
|
96
|
+
// Remounting silently over either reads as "Enter does nothing" (live incident).
|
|
97
|
+
const failure = attachFailureMessage(r)
|
|
98
|
+
if (failure) {
|
|
99
|
+
process.stdout.write(`attach ${action.personality} failed: ${failure}\npress Enter to continue…`)
|
|
92
100
|
// swallow one line so the operator sees the error before the alt-screen returns
|
|
93
101
|
spawnSync('sh', ['-c', 'read _'], { stdio: 'inherit' })
|
|
94
102
|
}
|