talon-agent 5.4.0 → 5.4.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/package.json +1 -1
- package/src/app.ts +4 -0
- package/src/core/daemon/respawn.ts +34 -10
- package/src/core/daemon/signals.ts +111 -0
- package/src/index.ts +7 -0
package/package.json
CHANGED
package/src/app.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { startWatchdog, stopWatchdog } from "./util/watchdog.js";
|
|
|
33
33
|
import {
|
|
34
34
|
BOOT_SMOKE_FLAG,
|
|
35
35
|
BOOT_SMOKE_OK,
|
|
36
|
+
setRespawnShutdown,
|
|
36
37
|
spawnSuccessor,
|
|
37
38
|
} from "./core/daemon/respawn.js";
|
|
38
39
|
import {
|
|
@@ -342,6 +343,9 @@ async function gracefulShutdown(signal: string): Promise<void> {
|
|
|
342
343
|
|
|
343
344
|
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
|
344
345
|
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
|
346
|
+
// /restart and /update come in here directly rather than through a
|
|
347
|
+
// self-sent SIGTERM — see core/daemon/respawn.ts.
|
|
348
|
+
setRespawnShutdown((reason) => void gracefulShutdown(`respawn (${reason})`));
|
|
345
349
|
|
|
346
350
|
// Cleanup runs before the crash is reported (and the EPIPE suppression
|
|
347
351
|
// is unchanged) — see core/daemon/crash.ts for why the order matters.
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
* systemd, foreman, pm2, or running under a debugger. Respawning from
|
|
14
14
|
* our own `process.argv` works regardless of launch method.
|
|
15
15
|
*
|
|
16
|
-
* Ordering matters. `respawnSelf()` only *arms* the handoff and
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* Ordering matters. `respawnSelf()` only *arms* the handoff and enters
|
|
17
|
+
* graceful shutdown; the successor is spawned by `spawnSuccessor()` at
|
|
18
|
+
* the tail of it, once the frontends have stopped. Spawning up-front
|
|
19
19
|
* (the original behaviour) left the successor long-polling `getUpdates`
|
|
20
20
|
* while the outgoing process was still draining in-flight queries — up
|
|
21
21
|
* to DRAIN_TIMEOUT_MS of two live pollers. Telegram answers only one of
|
|
@@ -40,6 +40,16 @@
|
|
|
40
40
|
* within a bounded window, and starts the daemon the way `talon
|
|
41
41
|
* start` does if the successor never comes up. Nothing in the
|
|
42
42
|
* handoff depends on a process that is about to call process.exit().
|
|
43
|
+
*
|
|
44
|
+
* And one thing 2026-09-20 added: the shutdown is entered directly,
|
|
45
|
+
* through the function app.ts registers with `setRespawnShutdown()`,
|
|
46
|
+
* not by sending ourselves a SIGTERM. The signal round trip bought
|
|
47
|
+
* nothing, and under Bun it lost the whole restart: by the time
|
|
48
|
+
* `/restart` ran, the process had silently lost its OS-level SIGTERM
|
|
49
|
+
* handler (see ./signals.ts), so the signal terminated it on the spot —
|
|
50
|
+
* no shutdown, no successor, no pidfile cleanup, and nothing in any log
|
|
51
|
+
* after "Respawn requested". Only a process that never registered — an
|
|
52
|
+
* embedder, a test — still falls back to the signal.
|
|
43
53
|
*/
|
|
44
54
|
|
|
45
55
|
import { spawn } from "node:child_process";
|
|
@@ -47,6 +57,18 @@ import { log, logError, openRespawnLog } from "../../util/log.js";
|
|
|
47
57
|
import { HANDOFF_WATCH_SUBCOMMAND } from "./handoff.js";
|
|
48
58
|
|
|
49
59
|
let pendingReason: string | null = null;
|
|
60
|
+
let shutdown: ((reason: string) => void) | null = null;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Register the graceful-shutdown entry a respawn should run. app.ts
|
|
64
|
+
* hands over `gracefulShutdown`; `respawnSelf()` then calls it in-process
|
|
65
|
+
* instead of round-tripping a SIGTERM through the OS. `null` clears it.
|
|
66
|
+
*/
|
|
67
|
+
export function setRespawnShutdown(
|
|
68
|
+
fn: ((reason: string) => void) | null,
|
|
69
|
+
): void {
|
|
70
|
+
shutdown = fn;
|
|
71
|
+
}
|
|
50
72
|
|
|
51
73
|
/**
|
|
52
74
|
* Argv flag that makes the daemon entry resolve its whole import graph
|
|
@@ -84,9 +106,8 @@ function selfInvocation(extra: string[]): { cmd: string; args: string[] } {
|
|
|
84
106
|
}
|
|
85
107
|
|
|
86
108
|
/**
|
|
87
|
-
* Arm a respawn and
|
|
88
|
-
*
|
|
89
|
-
* and hands off via `spawnSuccessor()`.
|
|
109
|
+
* Arm a respawn and enter graceful shutdown, which stops the frontends,
|
|
110
|
+
* flushes state, and hands off via `spawnSuccessor()`.
|
|
90
111
|
*
|
|
91
112
|
* `reason` is logged for operator visibility (e.g. "telegram
|
|
92
113
|
* /restart"). The function returns immediately; the successor starts
|
|
@@ -95,10 +116,13 @@ function selfInvocation(extra: string[]): { cmd: string; args: string[] } {
|
|
|
95
116
|
export function respawnSelf(reason: string): void {
|
|
96
117
|
log("shutdown", `Respawn requested (${reason})`);
|
|
97
118
|
pendingReason = reason;
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
119
|
+
// Don't exit here directly — that would skip the flush and leave the
|
|
120
|
+
// PID file dangling. The registered shutdown is the same path a
|
|
121
|
+
// SIGTERM takes, entered without the signal.
|
|
122
|
+
if (shutdown) {
|
|
123
|
+
shutdown(reason);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
102
126
|
process.kill(process.pid, "SIGTERM");
|
|
103
127
|
}
|
|
104
128
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep OS-level signal handlers armed under Bun.
|
|
3
|
+
*
|
|
4
|
+
* Bun (observed on 1.3.9) uninstalls the process-wide handler for a
|
|
5
|
+
* signal as soon as ANY listener for it is removed — even while other
|
|
6
|
+
* listeners remain:
|
|
7
|
+
*
|
|
8
|
+
* process.on("SIGTERM", a);
|
|
9
|
+
* process.on("SIGTERM", b);
|
|
10
|
+
* process.removeListener("SIGTERM", b);
|
|
11
|
+
* // `a` is still registered in JS, yet SigCgt in /proc/<pid>/status
|
|
12
|
+
* // no longer lists SIGTERM: the next one terminates the process
|
|
13
|
+
* // with the default action, running no JS at all.
|
|
14
|
+
*
|
|
15
|
+
* Node keeps the handler until the last listener goes. Adding any
|
|
16
|
+
* listener makes Bun install the handler again, which is what this
|
|
17
|
+
* guard relies on.
|
|
18
|
+
*
|
|
19
|
+
* Talon hit it through `write-file-atomic`: each call loads signal-exit,
|
|
20
|
+
* which puts its own listeners on the termination signals, and unloads
|
|
21
|
+
* it afterwards, removing them. The daemon writes its pidfile that way
|
|
22
|
+
* while booting — so from that moment every SIGTERM it sent itself
|
|
23
|
+
* (`/restart` and `/update`, before respawn.ts entered the shutdown
|
|
24
|
+
* directly) or received from outside (`talon stop`'s fallback, systemd,
|
|
25
|
+
* docker) killed it outright: no graceful shutdown, no successor, no
|
|
26
|
+
* pidfile cleanup. Every `/update` between 2026-09-18 (the move to Bun)
|
|
27
|
+
* and 2026-09-20 ended this way.
|
|
28
|
+
*
|
|
29
|
+
* The guard wraps `process.removeListener` / `process.off`. After a
|
|
30
|
+
* removal that still leaves real listeners on a signal, it re-adds a
|
|
31
|
+
* no-op sentinel listener (removing any earlier copy first), so the
|
|
32
|
+
* handler is installed again before the caller gets control back. Once
|
|
33
|
+
* the last real listener is gone the sentinel goes too, and the signal
|
|
34
|
+
* falls back to its default action exactly as it would on Node — a
|
|
35
|
+
* lingering no-op listener would otherwise make the process ignore it.
|
|
36
|
+
*
|
|
37
|
+
* Installed by the entry shim (src/index.ts) so every Talon process —
|
|
38
|
+
* daemon, MCP supervisor, handoff watcher — is covered before any
|
|
39
|
+
* library gets a chance to remove a listener. A no-op on other runtimes.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { isBunRuntime } from "../../util/runtime.js";
|
|
43
|
+
|
|
44
|
+
type Listener = (...args: unknown[]) => void;
|
|
45
|
+
|
|
46
|
+
/** The slice of `process` the guard touches; tests pass an EventEmitter. */
|
|
47
|
+
export interface SignalEmitter {
|
|
48
|
+
on(event: string | symbol, listener: Listener): unknown;
|
|
49
|
+
removeListener(event: string | symbol, listener: Listener): unknown;
|
|
50
|
+
off?(event: string | symbol, listener: Listener): unknown;
|
|
51
|
+
listeners(event: string | symbol): readonly unknown[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface SignalGuardOptions {
|
|
55
|
+
/** Install even off Bun (tests exercise the wrapper on Node). */
|
|
56
|
+
force?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const SIGNAL_EVENT = /^SIG[A-Z0-9]+$/;
|
|
60
|
+
|
|
61
|
+
const INSTALLED = Symbol.for("talon.signalListenerGuard");
|
|
62
|
+
|
|
63
|
+
type Guarded = SignalEmitter & { [INSTALLED]?: () => void };
|
|
64
|
+
|
|
65
|
+
/** The sentinel. Its only job is to exist, so Bun keeps the handler. */
|
|
66
|
+
function talonSignalRearm(): void {}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Wrap the emitter's listener removal so a signal never silently loses
|
|
70
|
+
* its OS handler. Idempotent: a second install returns the first one's
|
|
71
|
+
* uninstall. Production never uninstalls; tests do.
|
|
72
|
+
*/
|
|
73
|
+
export function installSignalListenerGuard(
|
|
74
|
+
target: SignalEmitter = process,
|
|
75
|
+
opts: SignalGuardOptions = {},
|
|
76
|
+
): () => void {
|
|
77
|
+
const emitter = target as Guarded;
|
|
78
|
+
if (!opts.force && !isBunRuntime()) return () => {};
|
|
79
|
+
const existing = emitter[INSTALLED];
|
|
80
|
+
if (existing) return existing;
|
|
81
|
+
|
|
82
|
+
const originalRemove = emitter.removeListener;
|
|
83
|
+
const originalOff = emitter.off;
|
|
84
|
+
|
|
85
|
+
const guardedRemove = function (
|
|
86
|
+
this: Guarded,
|
|
87
|
+
event: string | symbol,
|
|
88
|
+
listener: Listener,
|
|
89
|
+
): unknown {
|
|
90
|
+
const result = originalRemove.call(this, event, listener);
|
|
91
|
+
if (typeof event !== "string" || !SIGNAL_EVENT.test(event)) return result;
|
|
92
|
+
// Bun has just dropped the handler. What is left decides whether the
|
|
93
|
+
// signal should still be handled at all.
|
|
94
|
+
const remaining = this.listeners(event).filter(
|
|
95
|
+
(fn) => fn !== talonSignalRearm,
|
|
96
|
+
);
|
|
97
|
+
originalRemove.call(this, event, talonSignalRearm);
|
|
98
|
+
if (remaining.length > 0) this.on(event, talonSignalRearm);
|
|
99
|
+
return result;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
emitter.removeListener = guardedRemove;
|
|
103
|
+
if (originalOff) emitter.off = guardedRemove;
|
|
104
|
+
const uninstall = (): void => {
|
|
105
|
+
emitter.removeListener = originalRemove;
|
|
106
|
+
if (originalOff) emitter.off = originalOff;
|
|
107
|
+
delete emitter[INSTALLED];
|
|
108
|
+
};
|
|
109
|
+
emitter[INSTALLED] = uninstall;
|
|
110
|
+
return uninstall;
|
|
111
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* backends/frontends/plugins.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { installSignalListenerGuard } from "./core/daemon/signals.js";
|
|
13
14
|
import {
|
|
14
15
|
MCP_LAUNCH_SUBCOMMAND,
|
|
15
16
|
runSupervisor,
|
|
@@ -20,6 +21,12 @@ import {
|
|
|
20
21
|
runHandoffWatch,
|
|
21
22
|
} from "./core/daemon/handoff.js";
|
|
22
23
|
|
|
24
|
+
// Before anything can remove a signal listener: under Bun that would
|
|
25
|
+
// silently disarm SIGTERM/SIGINT for the whole process (core/daemon/
|
|
26
|
+
// signals.ts). Every Talon process passes through here, so every one
|
|
27
|
+
// is covered.
|
|
28
|
+
installSignalListenerGuard();
|
|
29
|
+
|
|
23
30
|
if (process.argv[2] === MCP_LAUNCH_SUBCOMMAND) {
|
|
24
31
|
await runSupervisor(process.argv.slice(3));
|
|
25
32
|
} else if (process.argv[2] === LUA_RUN_SUBCOMMAND) {
|