···88- **Browser view sanitization** — pressing `O` to open email in browser now injects a Content-Security-Policy that blocks JavaScript, iframes, and embedded objects (`script-src 'none'; frame-src 'none'; object-src 'none'`) while allowing remote images
99- **Reader space chord hints** — pressing `space` in the reader now shows all available actions (`1-0 links`, `d download .eml`, `l11-99 links 11+`) instead of only link info; `space+d` for EML download now works even when no links are present
1010- **Colored attachments in reader** — attachment filenames in the reader header are now rendered in waveAqua2 color instead of dim gray for better visibility
1111+- **Panic recovery** — all background goroutines (mark-as-read, spy pixel cache, temp file cleanup) are now wrapped with `safeGo()` which recovers panics instead of crashing the TUI; panics are logged to `~/.cache/neomd/crash.log` with timestamp and full stack trace for post-mortem debugging
1212+- **IMAP connection health check** — after 2+ minutes of inactivity (e.g. laptop suspend/resume), neomd probes the connection with IMAP NOOP before the next operation; if the connection is dead, it automatically reconnects — no more manual `R` refresh needed after sleep
1313+- **IMAP retry for read-only operations** — read-only IMAP commands (FETCH, SEARCH, STATUS) automatically retry once after reconnecting on network error; mutating operations (MOVE, APPEND, STORE) are NOT retried to prevent duplicate emails or replayed mutations
1414+- **MIME charset/encoding fallback** — emails with unknown charsets (ISO-8859-15, Windows-1256) or unknown transfer encodings no longer fail; neomd continues with raw bytes instead of crashing, matching aerc's graceful degradation pattern
1515+- **Config validation** — config is now validated on load: IMAP/SMTP addresses checked for valid `host:port` format with port range 1-65535, required fields enforced, UI values checked for non-negative ranges; clear error messages instead of silent failures
1616+- **Integration tests for security features** — new `TestIntegration_SecurityFeatures` (disguised attachment + callout) and `TestIntegration_BrowserSanitization` (CSP script/iframe blocking) send real test emails for live inspection
11171218# 2026-04-27
1319- **Mailto handler (`--mailto` / positional URI)** — neomd can now be used as the system default `mailto:` handler; clicking a `mailto:` link in any browser opens a foot terminal with neomd in compose mode, pre-filled with To, CC, BCC, Subject, and Body from the URI; supports both `neomd --mailto "mailto:user@example.com?subject=Hello"` and `neomd "mailto:..."` (positional, for `.desktop` integration); registered via `xdg-mime` with a `neomd-mailto.desktop` file; after sending or cancelling, neomd continues as normal
+82
internal/config/config.go
···3344import (
55 "fmt"
66+ "net"
67 "os"
78 "path/filepath"
89 "runtime"
1010+ "strconv"
911 "strings"
10121113 "github.com/BurntSushi/toml"
···271273 return p
272274}
273275276276+// CrashLogPath returns the path for the crash log file.
277277+func CrashLogPath() string {
278278+ if dir, err := os.UserCacheDir(); err == nil {
279279+ p := filepath.Join(dir, cacheDirName)
280280+ _ = os.MkdirAll(p, 0700)
281281+ return filepath.Join(p, "crash.log")
282282+ }
283283+ return filepath.Join(os.TempDir(), fmt.Sprintf("neomd_%d_crash.log", os.Getuid()))
284284+}
285285+274286// SpyPixelCachePath returns the path for the spy pixel cache file.
275287func SpyPixelCachePath() string {
276288 if dir, err := os.UserCacheDir(); err == nil {
···358370359371 cfg.Listmonk.APIToken = expandEnv(cfg.Listmonk.APIToken)
360372373373+ if err := cfg.validate(); err != nil {
374374+ return nil, fmt.Errorf("config validation: %w", err)
375375+ }
376376+361377 return cfg, nil
378378+}
379379+380380+// validate checks config values for common mistakes.
381381+func (cfg *Config) validate() error {
382382+ if len(cfg.Accounts) == 0 && cfg.Account.IMAP == "" {
383383+ return fmt.Errorf("no accounts configured — add at least one [[accounts]] section")
384384+ }
385385+ for i, a := range cfg.Accounts {
386386+ label := a.Name
387387+ if label == "" {
388388+ label = fmt.Sprintf("accounts[%d]", i)
389389+ }
390390+ if a.IMAP == "" {
391391+ return fmt.Errorf("account %q: imap address is required", label)
392392+ }
393393+ if a.SMTP == "" {
394394+ return fmt.Errorf("account %q: smtp address is required", label)
395395+ }
396396+ if err := validateHostPort(a.IMAP, label, "imap"); err != nil {
397397+ return err
398398+ }
399399+ if err := validateHostPort(a.SMTP, label, "smtp"); err != nil {
400400+ return err
401401+ }
402402+ if a.User == "" && !a.IsOAuth2() {
403403+ return fmt.Errorf("account %q: user is required", label)
404404+ }
405405+ }
406406+ // Validate legacy single-account fields if used
407407+ if cfg.Account.IMAP != "" {
408408+ if err := validateHostPort(cfg.Account.IMAP, "account", "imap"); err != nil {
409409+ return err
410410+ }
411411+ if cfg.Account.SMTP != "" {
412412+ if err := validateHostPort(cfg.Account.SMTP, "account", "smtp"); err != nil {
413413+ return err
414414+ }
415415+ }
416416+ }
417417+ // Validate UI settings
418418+ if cfg.UI.InboxCount < 0 {
419419+ return fmt.Errorf("ui.inbox_count must be >= 0, got %d", cfg.UI.InboxCount)
420420+ }
421421+ if cfg.UI.BgSyncInterval < 0 {
422422+ return fmt.Errorf("ui.bg_sync_interval must be >= 0, got %d", cfg.UI.BgSyncInterval)
423423+ }
424424+ if cfg.UI.MarkAsReadAfterSecs < 0 {
425425+ return fmt.Errorf("ui.mark_as_read_after_secs must be >= 0, got %d", cfg.UI.MarkAsReadAfterSecs)
426426+ }
427427+ return nil
428428+}
429429+430430+// validateHostPort checks that an address is in host:port format with a valid port.
431431+func validateHostPort(addr, account, field string) error {
432432+ host, portStr, err := net.SplitHostPort(addr)
433433+ if err != nil {
434434+ return fmt.Errorf("account %q: %s %q is not valid host:port — %w", account, field, addr, err)
435435+ }
436436+ if host == "" {
437437+ return fmt.Errorf("account %q: %s host is empty in %q", account, field, addr)
438438+ }
439439+ port, err := strconv.Atoi(portStr)
440440+ if err != nil || port < 1 || port > 65535 {
441441+ return fmt.Errorf("account %q: %s port %q is not a valid port (1-65535)", account, field, portStr)
442442+ }
443443+ return nil
362444}
363445364446func defaults() *Config {
···7272 mu sync.Mutex
7373 conn *imapclient.Client
7474 selectedMailbox string
7575+ lastActivity time.Time // tracks last successful operation for health checks
7576}
76777778// New creates a new IMAP client (does not connect yet).
···157158 return c.connect(ctx)
158159}
159160161161+// withConn runs fn on the IMAP connection, reconnecting if needed.
162162+// Does NOT retry on network errors — safe for mutating operations (APPEND, MOVE, STORE).
160163func (c *Client) withConn(ctx context.Context, fn func(*imapclient.Client) error) error {
164164+ return c.withConnRetryable(ctx, fn, false)
165165+}
166166+167167+// withConnRetry runs fn on the IMAP connection with one automatic retry on network error.
168168+// Only safe for idempotent/read-only operations (FETCH, SEARCH, SELECT, NOOP).
169169+func (c *Client) withConnRetry(ctx context.Context, fn func(*imapclient.Client) error) error {
170170+ return c.withConnRetryable(ctx, fn, true)
171171+}
172172+173173+func (c *Client) withConnRetryable(ctx context.Context, fn func(*imapclient.Client) error, retry bool) error {
161174 c.mu.Lock()
162175 defer c.mu.Unlock()
163176 if err := c.connect(ctx); err != nil {
164177 return err
165178 }
179179+ // After 2+ minutes of inactivity (e.g. laptop suspend/resume),
180180+ // probe the connection with NOOP before running the real operation.
181181+ if !c.lastActivity.IsZero() && time.Since(c.lastActivity) > 2*time.Minute {
182182+ if err := c.conn.Noop().Wait(); err != nil {
183183+ _ = c.conn.Close()
184184+ c.conn = nil
185185+ c.selectedMailbox = ""
186186+ if err := c.connect(ctx); err != nil {
187187+ return err
188188+ }
189189+ }
190190+ }
166191 if err := fn(c.conn); err != nil {
167192 if isNetErr(err) {
168193 _ = c.conn.Close()
169194 c.conn = nil
170195 c.selectedMailbox = ""
196196+ if retry {
197197+ time.Sleep(1 * time.Second)
198198+ if err := c.connect(ctx); err != nil {
199199+ return err
200200+ }
201201+ if err := fn(c.conn); err != nil {
202202+ if isNetErr(err) {
203203+ _ = c.conn.Close()
204204+ c.conn = nil
205205+ c.selectedMailbox = ""
206206+ }
207207+ return err
208208+ }
209209+ c.lastActivity = time.Now()
210210+ return nil
211211+ }
171212 }
172213 return err
173214 }
215215+ c.lastActivity = time.Now()
174216 return nil
175217}
176218···220262 if ctx == nil {
221263 ctx = context.Background()
222264 }
223223- return c.withConn(ctx, func(conn *imapclient.Client) error {
265265+ return c.withConnRetry(ctx, func(conn *imapclient.Client) error {
224266 return conn.Noop().Wait()
225267 })
226268}
···231273 ctx = context.Background()
232274 }
233275 var emails []Email
234234- err := c.withConn(ctx, func(conn *imapclient.Client) error {
276276+ err := c.withConnRetry(ctx, func(conn *imapclient.Client) error {
235277 emails = nil // reset on retry to avoid duplicates
236278 if err := c.selectMailbox(folder); err != nil {
237279 return err
···354396 ctx = context.Background()
355397 }
356398 var uids []uint32
357357- err := c.withConn(ctx, func(conn *imapclient.Client) error {
399399+ err := c.withConnRetry(ctx, func(conn *imapclient.Client) error {
358400 uids = nil // reset on retry
359401 if err := c.selectMailbox(folder); err != nil {
360402 return err
···384426 if ctx == nil {
385427 ctx = context.Background()
386428 }
387387- counts := make(map[string]int, len(folders))
388388- err := c.withConn(ctx, func(conn *imapclient.Client) error {
429429+ var counts map[string]int
430430+ err := c.withConnRetry(ctx, func(conn *imapclient.Client) error {
431431+ counts = make(map[string]int, len(folders)) // reset on retry
389432 for label, mailbox := range folders {
390433 data, err := conn.Status(mailbox, &imap.StatusOptions{NumUnseen: true}).Wait()
391434 if err != nil {
435435+ if isNetErr(err) {
436436+ return err // let withConnRetry reconnect
437437+ }
392438 continue // folder may not exist; skip
393439 }
394440 if data.NumUnseen != nil {
···441487 criteria := buildSearchCriteria(query)
442488443489 var uids []uint32
444444- err := c.withConn(ctx, func(conn *imapclient.Client) error {
490490+ err := c.withConnRetry(ctx, func(conn *imapclient.Client) error {
445491 uids = nil // reset on retry
446492 if err := c.selectMailbox(folder); err != nil {
447493 return err
···652698 return nil, nil
653699 }
654700 var emails []Email
655655- err := c.withConn(ctx, func(conn *imapclient.Client) error {
701701+ err := c.withConnRetry(ctx, func(conn *imapclient.Client) error {
656702 emails = nil // reset on retry
657703 if err := c.selectMailbox(folder); err != nil {
658704 return err
···732778 var markdown, rawHTML, webURL, references string
733779 var attachments []Attachment
734780 var spyPixels SpyPixelInfo
735735- err := c.withConn(ctx, func(conn *imapclient.Client) error {
781781+ err := c.withConnRetry(ctx, func(conn *imapclient.Client) error {
736782 if err := c.selectMailbox(folder); err != nil {
737783 return err
738784 }
···766812 ctx = context.Background()
767813 }
768814 var spy SpyPixelInfo
769769- err := c.withConn(ctx, func(conn *imapclient.Client) error {
815815+ err := c.withConnRetry(ctx, func(conn *imapclient.Client) error {
770816 if err := c.selectMailbox(folder); err != nil {
771817 return err
772818 }
···795841// extractHTMLPart pulls just the text/html content from raw MIME bytes.
796842func extractHTMLPart(raw []byte) string {
797843 e, err := message.Read(bytes.NewReader(raw))
798798- if err != nil && !message.IsUnknownCharset(err) {
844844+ if err != nil && !message.IsUnknownCharset(err) && !message.IsUnknownEncoding(err) {
799845 return ""
800846 }
801847 mr := mail.NewReader(e)
802848 for {
803849 p, err := mr.NextPart()
850850+ if err == io.EOF {
851851+ break
852852+ }
804853 if err != nil {
805805- break
854854+ if !message.IsUnknownCharset(err) && !message.IsUnknownEncoding(err) {
855855+ break
856856+ }
857857+ if p == nil {
858858+ continue
859859+ }
806860 }
807861 if h, ok := p.Header.(*mail.InlineHeader); ok {
808862 ct, _, _ := h.ContentType()
···821875 ctx = context.Background()
822876 }
823877 var raw []byte
824824- err := c.withConn(ctx, func(conn *imapclient.Client) error {
878878+ err := c.withConnRetry(ctx, func(conn *imapclient.Client) error {
825879 if err := c.selectMailbox(folder); err != nil {
826880 return err
827881 }
···915969 if err != nil {
916970 var imapErr *imap.Error
917971 if errors.As(err, &imapErr) && imapErr.Code == imap.ResponseCodeAlreadyExists {
918918- continue // already there, nothing to do
972972+ // Folder exists — still ensure it's subscribed
973973+ _ = conn.Subscribe(folder).Wait()
974974+ continue
919975 }
920976 return fmt.Errorf("CREATE %s: %w", folder, err)
921977 }
···10731129// preamble (e.g. Substack's "View this post on the web at https://…")
10741130func parseBody(raw []byte) (markdown, rawHTML, webURL string, attachments []Attachment, references string, spyPixels SpyPixelInfo) {
10751131 e, err := message.Read(bytes.NewReader(raw))
10761076- if err != nil && !message.IsUnknownCharset(err) {
11321132+ if err != nil && !message.IsUnknownCharset(err) && !message.IsUnknownEncoding(err) {
10771133 return string(raw), "", "", nil, "", SpyPixelInfo{}
10781134 }
10791135···11071163 break
11081164 }
11091165 if err != nil {
11101110- if !message.IsUnknownCharset(err) {
11661166+ if !message.IsUnknownCharset(err) && !message.IsUnknownEncoding(err) {
11111167 break
11121168 }
11691169+ // Unknown charset/encoding — continue with raw bytes rather than failing
11131170 if p == nil {
11141171 continue
11151172 }
+91
internal/imap/client_test.go
···44 "context"
55 "strings"
66 "testing"
77+ "time"
7889 imap "github.com/emersion/go-imap/v2"
910)
···480481481482 if spy.Count != 0 {
482483 t.Errorf("plain-text email SpyPixelInfo.Count = %d, want 0", spy.Count)
484484+ }
485485+}
486486+487487+func TestParseBody_UnknownCharset(t *testing.T) {
488488+ // Emails with unknown charsets should not fail — they should render
489489+ // with raw bytes rather than crashing. This is common with legacy
490490+ // encodings (ISO-8859-15, Windows-1256, etc.).
491491+ raw := "MIME-Version: 1.0\r\n" +
492492+ "Content-Type: text/plain; charset=x-unknown-charset-999\r\n" +
493493+ "Content-Transfer-Encoding: 7bit\r\n" +
494494+ "\r\n" +
495495+ "This email uses an unknown charset but should still be readable."
496496+497497+ body, _, _, _, _, _ := parseBody([]byte(raw))
498498+499499+ if body == "" {
500500+ t.Error("parseBody returned empty body for unknown charset — should fall back to raw bytes")
501501+ }
502502+ if !strings.Contains(body, "unknown charset") {
503503+ t.Errorf("expected body to contain raw text, got: %q", body)
504504+ }
505505+}
506506+507507+func TestParseBody_UnknownEncoding(t *testing.T) {
508508+ // Emails with unknown transfer encodings should degrade gracefully.
509509+ raw := "MIME-Version: 1.0\r\n" +
510510+ "Content-Type: text/plain; charset=utf-8\r\n" +
511511+ "Content-Transfer-Encoding: x-uuencode\r\n" +
512512+ "\r\n" +
513513+ "This email uses an unusual encoding."
514514+515515+ body, _, _, _, _, _ := parseBody([]byte(raw))
516516+517517+ // Should not panic or return empty — may return raw bytes or partial content
518518+ if body == "" {
519519+ t.Error("parseBody returned empty body for unknown encoding — should not crash")
520520+ }
521521+}
522522+523523+func TestParseBody_MultipartUnknownCharset(t *testing.T) {
524524+ // Multipart email where one part has an unknown charset.
525525+ // The other part should still be parsed correctly.
526526+ boundary := "test-boundary-charset"
527527+ raw := "MIME-Version: 1.0\r\n" +
528528+ "Content-Type: multipart/alternative; boundary=" + boundary + "\r\n" +
529529+ "\r\n" +
530530+ "--" + boundary + "\r\n" +
531531+ "Content-Type: text/plain; charset=x-fake-charset\r\n" +
532532+ "\r\n" +
533533+ "Plain text with unknown charset\r\n" +
534534+ "--" + boundary + "\r\n" +
535535+ "Content-Type: text/html; charset=utf-8\r\n" +
536536+ "\r\n" +
537537+ "<html><body><p>HTML part is fine</p></body></html>\r\n" +
538538+ "--" + boundary + "--\r\n"
539539+540540+ body, _, _, _, _, _ := parseBody([]byte(raw))
541541+542542+ if body == "" {
543543+ t.Error("parseBody returned empty body for multipart with unknown charset")
544544+ }
545545+}
546546+547547+func TestConnectionHealthCheck_LastActivity(t *testing.T) {
548548+ // Verify that lastActivity is tracked by the Client struct.
549549+ // We can't test the actual NOOP probe without a real IMAP server,
550550+ // but we can verify the field exists and the logic is wired up.
551551+ c := &Client{
552552+ cfg: Config{
553553+ Host: "imap.example.com",
554554+ Port: "993",
555555+ TLS: true,
556556+ },
557557+ }
558558+559559+ // Initially zero — first withConn should not trigger NOOP
560560+ if !c.lastActivity.IsZero() {
561561+ t.Error("lastActivity should be zero on new Client")
562562+ }
563563+564564+ // After setting lastActivity to recent, NOOP should not trigger
565565+ c.lastActivity = time.Now()
566566+ if time.Since(c.lastActivity) > 2*time.Minute {
567567+ t.Error("recent lastActivity should not trigger health check")
568568+ }
569569+570570+ // After setting lastActivity to 3 minutes ago, NOOP should trigger
571571+ c.lastActivity = time.Now().Add(-3 * time.Minute)
572572+ if time.Since(c.lastActivity) <= 2*time.Minute {
573573+ t.Error("stale lastActivity (3min ago) should trigger health check")
483574 }
484575}
485576
+36-8
internal/ui/model.go
···3344import (
55 "fmt"
66+ "log"
67 "net/http"
78 "os"
89 "os/exec"
910 "path/filepath"
1011 "regexp"
1212+ "runtime/debug"
1113 "sort"
1214 "strconv"
1315 "strings"
···18111813 }
18121814 if !m.spyScannedKeys[key] {
18131815 m.spyScannedKeys[key] = true
18141814- go saveSpyPixelCache(copyMap(m.spyPixelKeys), copyMap(m.spyScannedKeys))
18161816+ safeGo(func() { saveSpyPixelCache(copyMap(m.spyPixelKeys), copyMap(m.spyScannedKeys)) })
18151817 }
18161818 }
18171819 // Store References header in the email struct for threading
···18221824 uid := msg.email.UID
18231825 folder := msg.email.Folder
18241826 markImmediately := func() {
18251825- go func() { _ = m.imapCli().MarkSeen(nil, folder, uid) }()
18271827+ safeGo(func() { _ = m.imapCli().MarkSeen(nil, folder, uid) })
18261828 // Update local state immediately
18271829 for i := range m.emails {
18281830 if m.emails[i].UID == uid && m.emails[i].Folder == folder {
···19711973 // Timer fired - mark email as read if user is still viewing it
19721974 if m.state == stateReading && m.markAsReadUID == msg.uid && m.markAsReadFolder == msg.folder {
19731975 // Still viewing the same email - mark it as read
19741974- go func() { _ = m.imapCli().MarkSeen(nil, msg.folder, msg.uid) }()
19761976+ safeGo(func() { _ = m.imapCli().MarkSeen(nil, msg.folder, msg.uid) })
19751977 // Update local state immediately
19761978 for i := range m.emails {
19771979 if m.emails[i].UID == msg.uid && m.emails[i].Folder == msg.folder {
···20672069 }
20682070 // Save cache and rebuild inbox on the main goroutine.
20692071 if len(msg.scannedKeys) > 0 {
20702070- go saveSpyPixelCache(copyMap(m.spyPixelKeys), copyMap(m.spyScannedKeys))
20722072+ safeGo(func() { saveSpyPixelCache(copyMap(m.spyPixelKeys), copyMap(m.spyScannedKeys)) })
20712073 }
20722074 return m, m.applyFilter()
20732075···29332935 _ = os.WriteFile(config.SpyPixelCachePath(), []byte(strings.Join(lines, "\n")+"\n"), 0600)
29342936}
2935293729382938+// safeGo runs fn in a goroutine with panic recovery. If the goroutine panics,
29392939+// the stack trace is logged to stderr and written to ~/.cache/neomd/crash.log.
29402940+func safeGo(fn func()) {
29412941+ go func() {
29422942+ defer func() {
29432943+ if r := recover(); r != nil {
29442944+ stack := debug.Stack()
29452945+ log.Printf("goroutine panic recovered: %v\n%s", r, stack)
29462946+ writeCrashLog(r, stack)
29472947+ }
29482948+ }()
29492949+ fn()
29502950+ }()
29512951+}
29522952+29532953+// writeCrashLog appends a panic record to the crash log file.
29542954+func writeCrashLog(r interface{}, stack []byte) {
29552955+ path := config.CrashLogPath()
29562956+ f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
29572957+ if err != nil {
29582958+ return
29592959+ }
29602960+ defer f.Close()
29612961+ fmt.Fprintf(f, "=== neomd crash at %s ===\npanic: %v\n%s\n\n", time.Now().Format(time.RFC3339), r, stack)
29622962+}
29632963+29362964// copyMap returns a shallow copy of a map, safe for passing to goroutines.
29372965func copyMap(m map[string]bool) map[string]bool {
29382966 c := make(map[string]bool, len(m))
···33863414 // xdg-open exits immediately after handing off to the browser process,
33873415 // so cmd.Wait() returns before the browser has read the file.
33883416 // Sleep long enough for any browser to finish loading from disk.
33893389- go func() {
34173417+ safeGo(func() {
33903418 time.Sleep(15 * time.Second)
33913419 os.Remove(tmpPath)
33923420 for _, p := range tmpImages {
33933421 os.Remove(p)
33943422 }
33953395- }()
34233423+ })
33963424 return nil
33973425 }
33983426}
···40304058 return m, func() tea.Msg {
40314059 cmd := exec.Command(browser, tmpPath)
40324060 _ = cmd.Start()
40334033- go func() {
40614061+ safeGo(func() {
40344062 time.Sleep(15 * time.Second)
40354063 os.Remove(tmpPath)
40364036- }()
40644064+ })
40374065 return nil
40384066 }
40394067}
+94
internal/ui/model_test.go
···2233import (
44 "net/http"
55+ "os"
56 "reflect"
67 "strings"
88+ "sync"
79 "testing"
1010+ "time"
811912 tea "github.com/charmbracelet/bubbletea"
1013 "github.com/sspaeti/neomd/internal/config"
···739742 })
740743 }
741744}
745745+746746+func TestSafeGo_RecoversPanic(t *testing.T) {
747747+ // safeGo should recover from panics without crashing the process.
748748+ // If this test passes, the goroutine panic was caught.
749749+ var wg sync.WaitGroup
750750+ wg.Add(1)
751751+752752+ completed := false
753753+ safeGo(func() {
754754+ defer wg.Done()
755755+ completed = true
756756+ panic("intentional test panic")
757757+ })
758758+759759+ // Wait for the goroutine to finish (panic should be recovered)
760760+ wg.Wait()
761761+762762+ if !completed {
763763+ t.Error("safeGo goroutine did not execute before panicking")
764764+ }
765765+ // If we reach here, the panic was recovered — test passes
766766+}
767767+768768+func TestSafeGo_NormalExecution(t *testing.T) {
769769+ // safeGo should work normally for non-panicking functions.
770770+ var wg sync.WaitGroup
771771+ wg.Add(1)
772772+773773+ result := 0
774774+ safeGo(func() {
775775+ defer wg.Done()
776776+ result = 42
777777+ })
778778+779779+ wg.Wait()
780780+781781+ if result != 42 {
782782+ t.Errorf("safeGo normal execution: got %d, want 42", result)
783783+ }
784784+}
785785+786786+func TestSafeGo_WritesCrashLog(t *testing.T) {
787787+ // safeGo should write panics to the crash log file.
788788+ var wg sync.WaitGroup
789789+ wg.Add(1)
790790+791791+ safeGo(func() {
792792+ defer wg.Done()
793793+ panic("crash log test panic")
794794+ })
795795+796796+ wg.Wait()
797797+ time.Sleep(100 * time.Millisecond) // let file write complete
798798+799799+ path := config.CrashLogPath()
800800+ data, err := os.ReadFile(path)
801801+ if err != nil {
802802+ t.Skipf("crash log not readable (may not exist in test env): %v", err)
803803+ }
804804+ if !strings.Contains(string(data), "crash log test panic") {
805805+ t.Error("crash log should contain the panic message")
806806+ }
807807+}
808808+809809+func TestSafeGo_MultiplePanics(t *testing.T) {
810810+ // Multiple concurrent panicking goroutines should all be recovered.
811811+ var wg sync.WaitGroup
812812+ count := 10
813813+ wg.Add(count)
814814+815815+ for i := 0; i < count; i++ {
816816+ safeGo(func() {
817817+ defer wg.Done()
818818+ panic("concurrent panic")
819819+ })
820820+ }
821821+822822+ // All should complete without crashing the process
823823+ done := make(chan struct{})
824824+ go func() {
825825+ wg.Wait()
826826+ close(done)
827827+ }()
828828+829829+ select {
830830+ case <-done:
831831+ // Success — all panics recovered
832832+ case <-time.After(5 * time.Second):
833833+ t.Fatal("timed out waiting for panicking goroutines to recover")
834834+ }
835835+}