@@ -510,6 +510,12 @@ func isProtocolSupported(proto string) bool {
510510 return false
511511}
512512
513+ // failDetail holds per-protocol failure reason counts.
514+ type failDetail struct {
515+ mu sync.Mutex
516+ reasons map [string ]int
517+ }
518+
513519func validateAll (lines []string ) []configResult {
514520 seen := make (map [string ]bool )
515521 byProto := make (map [string ][]string )
@@ -555,6 +561,66 @@ func validateAll(lines []string) []configResult {
555561 gLog .writeLine ("" )
556562 }
557563
564+
565+ // ── Per-protocol failure detail tracking ──────────────────────────────────
566+ protoFails := make (map [string ]* failDetail )
567+ for _ , p := range cfg .ProtocolOrder {
568+ protoFails [p ] = & failDetail {reasons : make (map [string ]int )}
569+ }
570+
571+ // normalizeReason converts a raw failReason into a short, groupable key.
572+ normalizeReason := func (reason string ) string {
573+ switch {
574+ case strings .HasPrefix (reason , "PARSE: base64:" ):
575+ return "PARSE: base64 decode error"
576+ case strings .HasPrefix (reason , "PARSE: json:" ):
577+ return "PARSE: json decode error"
578+ case strings .HasPrefix (reason , "PARSE: url parse:" ):
579+ return "PARSE: url parse error"
580+ case strings .HasPrefix (reason , "PARSE: unsupported cipher:" ):
581+ return "PARSE: unsupported cipher"
582+ case strings .HasPrefix (reason , "PARSE:" ):
583+ msg := strings .TrimPrefix (reason , "PARSE: " )
584+ if len (msg ) > 50 {
585+ msg = msg [:50 ]
586+ }
587+ return "PARSE: " + msg
588+ case strings .HasPrefix (reason , "SINGBOX_START:" ), strings .HasPrefix (reason , "START:" ):
589+ r := reason
590+ if i := strings .Index (r , ": " ); i != - 1 {
591+ r = r [i + 2 :]
592+ }
593+ // Strip ANSI escape codes
594+ r = strings .Map (func (c rune ) rune {
595+ if c == 0x1b {
596+ return - 1
597+ }
598+ return c
599+ }, r )
600+ if strings .Contains (r , "port not open" ) {
601+ return "START: port not open (timeout)"
602+ }
603+ if strings .Contains (r , "decode config" ) || strings .Contains (r , "outbound" ) {
604+ return "START: invalid config (sing-box rejected)"
605+ }
606+ if len (r ) > 60 {
607+ r = r [:60 ]
608+ }
609+ return "START: " + r
610+ case strings .HasPrefix (reason , "CONN:" ):
611+ r := strings .TrimPrefix (reason , "CONN: " )
612+ if i := strings .Index (r , " | " ); i != - 1 {
613+ r = r [:i ]
614+ }
615+ if len (r ) > 60 {
616+ r = r [:60 ]
617+ }
618+ return "CONN: " + r
619+ default :
620+ return reason
621+ }
622+ }
623+
558624 resultsCh := make (chan configResult , cfg .Validation .NumWorkers * 4 )
559625 sem := make (chan struct {}, cfg .Validation .NumWorkers )
560626
@@ -564,23 +630,26 @@ func validateAll(lines []string) []configResult {
564630 var failedStart int64
565631 var failedConn int64
566632
567- var outerWg sync.WaitGroup
568-
569- for _ , proto := range cfg .ProtocolOrder {
570- protoLines := byProto [proto ]
571- if len (protoLines ) == 0 {
572- continue
573- }
574- if gLog != nil {
575- gLog .logProtoStart (proto , len (protoLines ))
576- }
577- fmt .Printf ("\n 🔵 %s (%d configs)\n " , proto , len (protoLines ))
633+ // ── Sequential per-protocol processing ────────────────────────────────────
634+ // Each protocol is fully completed before the next one starts.
635+ // Within each protocol, workers run in parallel up to NumWorkers.
636+ go func () {
637+ for _ , proto := range cfg .ProtocolOrder {
638+ protoLines := byProto [proto ]
639+ if len (protoLines ) == 0 {
640+ continue
641+ }
642+ if gLog != nil {
643+ gLog .logProtoStart (proto , len (protoLines ))
644+ }
645+ protoStart := time .Now ()
646+ fmt .Printf ("\n 🔵 [%s] Starting — %d configs\n " , strings .ToUpper (proto ), len (protoLines ))
578647
579- outerWg .Add (1 )
580- go func (p string , pLines []string ) {
581- defer outerWg .Done ()
582648 var wg sync.WaitGroup
583- for _ , line := range pLines {
649+ var protoPassed int64
650+ var protoFailed int64
651+
652+ for _ , line := range protoLines {
584653 l := line
585654 wg .Add (1 )
586655 sem <- struct {}{}
@@ -589,38 +658,43 @@ func validateAll(lines []string) []configResult {
589658 defer func () { <- sem }()
590659
591660 idx := atomic .AddInt64 (& testedCount , 1 )
592- res := validate (l , p )
661+ res := validate (l , proto )
593662
594663 if gLog != nil {
595- gLog .logResult (idx , p , l , res )
664+ gLog .logResult (idx , proto , l , res )
596665 }
597666
598667 if res .totalScore >= cfg .Validation .MinPassScore {
599668 atomic .AddInt64 (& passedCount , 1 )
600- fmt . Printf ( "✅ [%d] PASS | %s | %dms \n " , idx , p , res . latency . Milliseconds () )
601- resultsCh <- configResult {line : l , proto : p , latency : res .latency }
669+ atomic . AddInt64 ( & protoPassed , 1 )
670+ resultsCh <- configResult {line : l , proto : proto , latency : res .latency }
602671 } else {
672+ atomic .AddInt64 (& protoFailed , 1 )
603673 reason := res .failReason
674+ norm := normalizeReason (reason )
675+ fd := protoFails [proto ]
676+ fd .mu .Lock ()
677+ fd .reasons [norm ]++
678+ fd .mu .Unlock ()
679+
604680 if strings .HasPrefix (reason , "PARSE:" ) {
605681 atomic .AddInt64 (& failedParse , 1 )
606682 } else if strings .HasPrefix (reason , "SINGBOX_START:" ) || strings .HasPrefix (reason , "START:" ) {
607683 atomic .AddInt64 (& failedStart , 1 )
608684 } else {
609685 atomic .AddInt64 (& failedConn , 1 )
610686 }
611- if idx <= 30 || strings .HasPrefix (reason , "PARSE:" ) || strings .HasPrefix (reason , "SINGBOX_START:" ) {
612- fmt .Printf ("❌ [%d] FAIL | %s | %s\n " , idx , p , shortenErr (reason ))
613- }
614687 }
615688 }()
616689 }
617690 wg .Wait ()
618- fmt .Printf ("✔️ %s done.\n " , p )
619- }(proto , protoLines )
620- }
621691
622- go func () {
623- outerWg .Wait ()
692+ elapsed := time .Since (protoStart ).Seconds ()
693+ total := int64 (len (protoLines ))
694+ passRate := float64 (protoPassed ) / float64 (total ) * 100
695+ fmt .Printf ("✅ [%s] Done — passed=%d/%d (%.1f%%) in %.1fs\n " ,
696+ strings .ToUpper (proto ), protoPassed , total , passRate , elapsed )
697+ }
624698 close (resultsCh )
625699 }()
626700
@@ -629,16 +703,78 @@ func validateAll(lines []string) []configResult {
629703 out = append (out , r )
630704 }
631705
706+ // ── Global summary line ────────────────────────────────────────────────────
632707 fmt .Printf ("\n 📊 Tested=%d | Passed=%d | ParseFail=%d | StartFail=%d | ConnFail=%d\n " ,
633708 atomic .LoadInt64 (& testedCount ),
634709 atomic .LoadInt64 (& passedCount ),
635710 atomic .LoadInt64 (& failedParse ),
636711 atomic .LoadInt64 (& failedStart ),
637712 atomic .LoadInt64 (& failedConn ))
638713
714+ // ── Detailed per-protocol failure report ─────────────────────────────────
715+ printFailureReport (protoFails , byProto )
716+
639717 return out
640718}
641719
720+ // printFailureReport prints a detailed statistical breakdown of failures per protocol.
721+ func printFailureReport (protoFails map [string ]* failDetail , byProto map [string ][]string ) {
722+ fmt .Println ()
723+ fmt .Println ("╔══════════════════════════════════════════════════════════════════════╗" )
724+ fmt .Println ("║ FAILURE ANALYSIS REPORT (per protocol) ║" )
725+ fmt .Println ("╚══════════════════════════════════════════════════════════════════════╝" )
726+
727+ for _ , proto := range cfg .ProtocolOrder {
728+ fd := protoFails [proto ]
729+ if fd == nil {
730+ continue
731+ }
732+ total := len (byProto [proto ])
733+ if total == 0 {
734+ continue
735+ }
736+ totalFails := 0
737+ for _ , c := range fd .reasons {
738+ totalFails += c
739+ }
740+ passed := total - totalFails
741+ passRate := float64 (passed ) / float64 (total ) * 100
742+
743+ fmt .Printf ("\n ┌─ %s ─── total=%d passed=%d failed=%d pass_rate=%.1f%%\n " ,
744+ strings .ToUpper (proto ), total , passed , totalFails , passRate )
745+
746+ if totalFails == 0 {
747+ fmt .Println ("│ (no failures)" )
748+ fmt .Println ("└" + strings .Repeat ("─" , 70 ))
749+ continue
750+ }
751+
752+ // Sort reasons by count descending
753+ type kv struct {
754+ key string
755+ val int
756+ }
757+ var sorted []kv
758+ for k , v := range fd .reasons {
759+ sorted = append (sorted , kv {k , v })
760+ }
761+ sort .Slice (sorted , func (i , j int ) bool { return sorted [i ].val > sorted [j ].val })
762+
763+ for i , item := range sorted {
764+ prefix := "│ ├"
765+ if i == len (sorted )- 1 {
766+ prefix = "│ └"
767+ }
768+ pct := float64 (item .val ) / float64 (totalFails ) * 100
769+ bar := strings .Repeat ("█" , int (pct / 5 ))
770+ fmt .Printf ("%s %-42s %5d (%5.1f%%) %s\n " ,
771+ prefix , item .key , item .val , pct , bar )
772+ }
773+ fmt .Println ("└" + strings .Repeat ("─" , 70 ))
774+ }
775+ fmt .Println ()
776+ }
777+
642778func validate (configURL , protocol string ) validationResult {
643779 result := validationResult {failReason : "unknown" }
644780
0 commit comments