mesh-mail-notifier: the watcher runs in the base invocation, not a separate notify process (issue 859)

The notify process's stdin is empty under systemd, so stdio.Serve returned at EOF, main returned, and
systemd restarted it every five seconds: the watch loop never ran long enough to show a notice. The watch
loop now starts beside stdio.Serve in the one process the node's runtime launches and keeps alive, as
mesh-knowledge's serve does. No resource declares a separate notify process any more.
This commit is contained in:
jochen
2026-10-15 23:13:34 +02:00
parent c9848f4fb8
commit 12d7852082
5 changed files with 109 additions and 27 deletions
+1 -1
View File
@@ -37,4 +37,4 @@ accounts it announces, in mesh-mail's trusted setting `grants` (`mail-store`'s s
| tool | does |
|---|---|
| `mail-notifier.status` | every account it is granted, when it last showed each one a notice, and any notice still failing to send (its count, last error, since when). Read-only |
| `mail_notifier_health` | how it is: unhealthy once a notice has failed to show for more than ten minutes, healthy otherwise, even mid-retry. Not (yet) the node-engine's own check of `notify` — one process cannot be judged only by its own tool (ADR 0227 rule 8); `notify`'s manifest health stays `unit` until the lead decides how a one-process module gets this automatically. Read-only |
| `mail_notifier_health` | how it is: unhealthy once a notice has failed to show for more than ten minutes, healthy otherwise, even mid-retry. Not (yet) the node-engine's own check — this is one process, judged only by its own tool (ADR 0227 rule 8); a watching process of its own is issue 841, not yet decided. Read-only |
@@ -1,13 +1,16 @@
// mesh-mail-notifier: the default claimant of mail-notifier, built from mesh-mail's own event shape (novox/hq
// ADR 0378 §9, issue 800 part B). A Go bundle the node's runtime launches, its own account (runs-as, ADR 0376
// §2): the base invocation serves mail-notifier.status and the module's own health tool
// (mail_notifier_health, review round 1 of issue 800); a separate process (`notify`) hears mesh-mail's added
// events and shows them. What this module says, it says on stderr; stdout is the MCP channel.
// §2): the one process serves mail-notifier.status and the module's own health tool (mail_notifier_health,
// review round 1 of issue 800), and, beside stdio.Serve, hears mesh-mail's added events and shows them
// (exactly as modules/mesh-knowledge's serve starts go hear(k) and go background(k) before stdio.Serve) — not
// a separate `notify` process, which systemd gave an empty stdin and so exited at once, every five seconds
// (novox/hq issue 859). What this module says, it says on stderr; stdout is the MCP channel.
package main
import (
"encoding/json"
"fmt"
"io"
"os"
"strings"
"time"
@@ -42,15 +45,6 @@ func main() {
}
n.init()
if len(os.Args) > 1 && os.Args[1] == "notify" {
go watch(n)
if err := stdio.Serve("", nil); err != nil {
logf("%v", err)
os.Exit(1)
}
return
}
tools := []stdio.Tool{
{Name: StatusTool,
Description: "The accounts mesh-mail-notifier announces on this machine, and when it last showed a " +
@@ -67,12 +61,22 @@ func main() {
return Judge(failures, time.Now()), nil
}},
}
if err := stdio.Serve("", tools); err != nil {
if err := run(n, watch, os.Stdin, os.Stdout, tools); err != nil {
logf("%v", err)
os.Exit(1)
}
}
// run is main's startup path, factored so a test can run it: the watcher started before stdio.Serve, exactly
// as mesh-knowledge's serve starts go hear(k) and go background(k) before stdio.Serve — so the watcher keeps
// running whatever stdio.Serve does with the stream it is given, including returning at once when stdin is
// already at EOF, as systemd gave the old separate `notify` process (novox/hq issue 859). The node's runtime
// launches this one process and keeps it alive.
func run(n *Notifier, watcher func(*Notifier), in io.Reader, out io.Writer, tools []stdio.Tool) error {
go watcher(n)
return stdio.ServeOn("", tools, in, out)
}
// watch hears mesh-mail's added events and, once a second, flushes what Notifier.Flush finds ready: an
// account's first mails of its window at once, and its folded overflow once the window is over (ADR 0378 §9,
// the lead's reading, review round 1 of issue 800).
@@ -0,0 +1,38 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// The manifest's own shape (novox/hq issue 859): the watch loop runs in the base invocation beside
// stdio.Serve, so no resource declares a separate `notify` process any more — the thing that exited at once,
// every five seconds, because a systemd-run process's stdin is empty and stdio.Serve returns at EOF.
type manifestResources struct {
Resources []map[string]any `json:"resources"`
}
func readManifestResources(t *testing.T) manifestResources {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
var m manifestResources
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("module.json: %v", err)
}
return m
}
func TestNoProcessNamedNotifyIsDeclared(t *testing.T) {
m := readManifestResources(t)
for _, r := range m.Resources {
if r["type"] == "process" {
t.Fatalf("module.json still declares a process resource (%v): the watcher runs in the base invocation now, not a separate process (novox/hq issue 859)", r)
}
}
}
@@ -0,0 +1,53 @@
package main
import (
"io"
"strings"
"testing"
"time"
)
// TestRunStartsTheWatcherEvenWhenStdinIsAtEOF proves the watcher runs beside stdio.Serve in the one process the
// node's runtime launches (novox/hq ADR 0378 §9, as mesh-knowledge's serve starts go hear(k) and go
// background(k) before stdio.Serve), not only in a separate process something keeps asking over stdin. run is
// given stdin already at EOF, exactly what systemd gave the old separate `notify` process: it exited at once,
// every five seconds (novox/hq issue 859). The watcher must still be running once run has returned.
func TestRunStartsTheWatcherEvenWhenStdinIsAtEOF(t *testing.T) {
started := make(chan struct{})
finished := make(chan struct{})
stop := make(chan struct{})
defer close(stop)
fakeWatcher := func(n *Notifier) {
close(started)
<-stop // the real watch never returns either: it loops until the process itself ends
close(finished)
}
n := &Notifier{Store: newFakeStore()}
n.init()
done := make(chan error, 1)
go func() { done <- run(n, fakeWatcher, strings.NewReader(""), io.Discard, nil) }()
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("the watcher was not started before stdio.Serve returned")
}
select {
case err := <-done:
if err != nil {
t.Fatalf("run over stdin at EOF: %v", err)
}
case <-time.After(time.Second):
t.Fatal("run did not return once stdin reached EOF, as systemd gives a process nobody talks to")
}
select {
case <-finished:
t.Fatal("the watcher must not stop just because stdio.Serve returned: it is the base invocation's own watcher now, not a separate `notify` process that exits at EOF")
default:
}
}
-13
View File
@@ -47,19 +47,6 @@
"mode": "0700",
"owner": "mesh-mail-notifier",
"place": "."
},
{
"id": "notify",
"type": "process",
"name": "mesh-mail-notifier-notify",
"artifact": "tools",
"run": [
"./mesh-mail-notifier",
"notify"
],
"health": {
"kind": "unit"
}
}
],
"build": {