fix(runtime): prevent stack shrinking across CEF callbacks
- commit
- 7fae873ff2395c85f60984e63cb983f5013afc64
- author
- brice · · 2026-07-27 02:58 UTC
- parent
- 3eb9180
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +97 -0
M cmd/dumber/main.go +71 -0
| @@ -146,7 +146,78 @@ type startupTiming struct { | |||
| 146 | 146 | configComplete time.Time | |
| 147 | 147 | } | |
| 148 | 148 | ||
| 149 | + | func environmentValue(environ []string, name string) string { | |
| 150 | + | prefix := name + "=" | |
| 151 | + | for _, entry := range environ { | |
| 152 | + | if strings.HasPrefix(entry, prefix) { | |
| 153 | + | return strings.TrimPrefix(entry, prefix) | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | return "" | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func replaceEnvironmentValue(environ []string, name, value string) []string { | |
| 160 | + | prefix := name + "=" | |
| 161 | + | updated := make([]string, 0, len(environ)+1) | |
| 162 | + | for _, entry := range environ { | |
| 163 | + | if !strings.HasPrefix(entry, prefix) { | |
| 164 | + | updated = append(updated, entry) | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | return append(updated, prefix+value) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func runtimeSafetyEnvironment(environ []string) ([]string, bool) { | |
| 171 | + | updated := append([]string(nil), environ...) | |
| 172 | + | const setting = "gcshrinkstackoff" | |
| 173 | + | parts := strings.Split(environmentValue(updated, "GODEBUG"), ",") | |
| 174 | + | kept := make([]string, 0, len(parts)+1) | |
| 175 | + | alreadySafe := false | |
| 176 | + | for _, part := range parts { | |
| 177 | + | part = strings.TrimSpace(part) | |
| 178 | + | if part == "" { | |
| 179 | + | continue | |
| 180 | + | } | |
| 181 | + | key, value, found := strings.Cut(part, "=") | |
| 182 | + | if key == setting { | |
| 183 | + | alreadySafe = found && value == "1" | |
| 184 | + | continue | |
| 185 | + | } | |
| 186 | + | kept = append(kept, part) | |
| 187 | + | } | |
| 188 | + | if alreadySafe { | |
| 189 | + | return updated, false | |
| 190 | + | } | |
| 191 | + | kept = append(kept, setting+"=1") | |
| 192 | + | updated = replaceEnvironmentValue(updated, "GODEBUG", strings.Join(kept, ",")) | |
| 193 | + | return updated, true | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func ensureRuntimeSafety() error { | |
| 197 | + | environ, reexec := runtimeSafetyEnvironment(os.Environ()) | |
| 198 | + | if !reexec { | |
| 199 | + | return nil | |
| 200 | + | } | |
| 201 | + | executable, err := os.Executable() | |
| 202 | + | if err != nil { | |
| 203 | + | return fmt.Errorf("resolve executable for runtime safety re-exec: %w", err) | |
| 204 | + | } | |
| 205 | + | if err := syscall.Exec(executable, os.Args, environ); err != nil { | |
| 206 | + | return fmt.Errorf("runtime safety re-exec: %w", err) | |
| 207 | + | } | |
| 208 | + | return nil | |
| 209 | + | } | |
| 210 | + | ||
| 149 | 211 | func main() { | |
| 212 | + | // purego callbacks cross foreign CEF/GTK stacks. Go stack shrinking can | |
| 213 | + | // invalidate frames retained across those boundaries and make the GC abort | |
| 214 | + | // with "traceback did not unwind completely". Re-exec once so the runtime | |
| 215 | + | // sees the safety setting before it creates any goroutine or CEF subprocess. | |
| 216 | + | if err := ensureRuntimeSafety(); err != nil { | |
| 217 | + | _, _ = fmt.Fprintf(os.Stderr, "dumber: %v\n", err) | |
| 218 | + | os.Exit(1) | |
| 219 | + | } | |
| 220 | + | ||
| 150 | 221 | // CEF is not known until configuration is complete. Keep these neutral | |
| 151 | 222 | // timestamps so a CEF GUI trace can be seeded truthfully without creating | |
| 152 | 223 | // one for WebKit, the standalone omnibox, CLI, or CEF helper processes. | |
| @@ -146,7 +146,78 @@ type startupTiming struct { | |||||
| 146 | configComplete time.Time | 146 | configComplete time.Time | ||
| 147 | } | 147 | } | ||
| 148 | 148 | ||||
| 149 | + | func environmentValue(environ []string, name string) string { | |||
| 150 | + | prefix := name + "=" | |||
| 151 | + | for _, entry := range environ { | |||
| 152 | + | if strings.HasPrefix(entry, prefix) { | |||
| 153 | + | return strings.TrimPrefix(entry, prefix) | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | return "" | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func replaceEnvironmentValue(environ []string, name, value string) []string { | |||
| 160 | + | prefix := name + "=" | |||
| 161 | + | updated := make([]string, 0, len(environ)+1) | |||
| 162 | + | for _, entry := range environ { | |||
| 163 | + | if !strings.HasPrefix(entry, prefix) { | |||
| 164 | + | updated = append(updated, entry) | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | return append(updated, prefix+value) | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func runtimeSafetyEnvironment(environ []string) ([]string, bool) { | |||
| 171 | + | updated := append([]string(nil), environ...) | |||
| 172 | + | const setting = "gcshrinkstackoff" | |||
| 173 | + | parts := strings.Split(environmentValue(updated, "GODEBUG"), ",") | |||
| 174 | + | kept := make([]string, 0, len(parts)+1) | |||
| 175 | + | alreadySafe := false | |||
| 176 | + | for _, part := range parts { | |||
| 177 | + | part = strings.TrimSpace(part) | |||
| 178 | + | if part == "" { | |||
| 179 | + | continue | |||
| 180 | + | } | |||
| 181 | + | key, value, found := strings.Cut(part, "=") | |||
| 182 | + | if key == setting { | |||
| 183 | + | alreadySafe = found && value == "1" | |||
| 184 | + | continue | |||
| 185 | + | } | |||
| 186 | + | kept = append(kept, part) | |||
| 187 | + | } | |||
| 188 | + | if alreadySafe { | |||
| 189 | + | return updated, false | |||
| 190 | + | } | |||
| 191 | + | kept = append(kept, setting+"=1") | |||
| 192 | + | updated = replaceEnvironmentValue(updated, "GODEBUG", strings.Join(kept, ",")) | |||
| 193 | + | return updated, true | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func ensureRuntimeSafety() error { | |||
| 197 | + | environ, reexec := runtimeSafetyEnvironment(os.Environ()) | |||
| 198 | + | if !reexec { | |||
| 199 | + | return nil | |||
| 200 | + | } | |||
| 201 | + | executable, err := os.Executable() | |||
| 202 | + | if err != nil { | |||
| 203 | + | return fmt.Errorf("resolve executable for runtime safety re-exec: %w", err) | |||
| 204 | + | } | |||
| 205 | + | if err := syscall.Exec(executable, os.Args, environ); err != nil { | |||
| 206 | + | return fmt.Errorf("runtime safety re-exec: %w", err) | |||
| 207 | + | } | |||
| 208 | + | return nil | |||
| 209 | + | } | |||
| 210 | + | ||||
| 149 | func main() { | 211 | func main() { | ||
| 212 | + | // purego callbacks cross foreign CEF/GTK stacks. Go stack shrinking can | |||
| 213 | + | // invalidate frames retained across those boundaries and make the GC abort | |||
| 214 | + | // with "traceback did not unwind completely". Re-exec once so the runtime | |||
| 215 | + | // sees the safety setting before it creates any goroutine or CEF subprocess. | |||
| 216 | + | if err := ensureRuntimeSafety(); err != nil { | |||
| 217 | + | _, _ = fmt.Fprintf(os.Stderr, "dumber: %v\n", err) | |||
| 218 | + | os.Exit(1) | |||
| 219 | + | } | |||
| 220 | + | ||||
| 150 | // CEF is not known until configuration is complete. Keep these neutral | 221 | // CEF is not known until configuration is complete. Keep these neutral | ||
| 151 | // timestamps so a CEF GUI trace can be seeded truthfully without creating | 222 | // timestamps so a CEF GUI trace can be seeded truthfully without creating | ||
| 152 | // one for WebKit, the standalone omnibox, CLI, or CEF helper processes. | 223 | // one for WebKit, the standalone omnibox, CLI, or CEF helper processes. | ||
M cmd/dumber/main_test.go +26 -0
| @@ -14,6 +14,32 @@ import ( | |||
| 14 | 14 | "github.com/rs/zerolog" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | + | func TestRuntimeSafetyEnvironmentDisablesStackShrinkingBeforeStartup(t *testing.T) { | |
| 18 | + | tests := []struct { | |
| 19 | + | name string | |
| 20 | + | environ []string | |
| 21 | + | wantGODEBUG string | |
| 22 | + | wantReexec bool | |
| 23 | + | }{ | |
| 24 | + | {name: "missing", environ: []string{"HOME=/tmp"}, wantGODEBUG: "gcshrinkstackoff=1", wantReexec: true}, | |
| 25 | + | {name: "preserves settings", environ: []string{"GODEBUG=gctrace=1"}, wantGODEBUG: "gctrace=1,gcshrinkstackoff=1", wantReexec: true}, | |
| 26 | + | {name: "overrides unsafe value", environ: []string{"GODEBUG=gcshrinkstackoff=0,gctrace=1"}, wantGODEBUG: "gctrace=1,gcshrinkstackoff=1", wantReexec: true}, | |
| 27 | + | {name: "already safe", environ: []string{"GODEBUG=gctrace=1,gcshrinkstackoff=1"}, wantGODEBUG: "gctrace=1,gcshrinkstackoff=1", wantReexec: false}, | |
| 28 | + | {name: "forged marker is ignored", environ: []string{"DUMBER_RUNTIME_SAFETY_REEXEC=1", "GODEBUG=gcshrinkstackoff=0"}, wantGODEBUG: "gcshrinkstackoff=1", wantReexec: true}, | |
| 29 | + | } | |
| 30 | + | for _, tt := range tests { | |
| 31 | + | t.Run(tt.name, func(t *testing.T) { | |
| 32 | + | got, reexec := runtimeSafetyEnvironment(tt.environ) | |
| 33 | + | if reexec != tt.wantReexec { | |
| 34 | + | t.Fatalf("reexec = %v, want %v", reexec, tt.wantReexec) | |
| 35 | + | } | |
| 36 | + | if value := environmentValue(got, "GODEBUG"); value != tt.wantGODEBUG { | |
| 37 | + | t.Fatalf("GODEBUG = %q, want %q", value, tt.wantGODEBUG) | |
| 38 | + | } | |
| 39 | + | }) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 17 | 43 | func TestLaunchModeFromArgs_DetectsStandaloneOmnibox(t *testing.T) { | |
| 18 | 44 | mode, _ := launchModeFromArgs([]string{"dumber", "omnibox"}) | |
| 19 | 45 | if mode != launchModeStandaloneOmnibox { | |
| @@ -14,6 +14,32 @@ import ( | |||||
| 14 | "github.com/rs/zerolog" | 14 | "github.com/rs/zerolog" | ||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | + | func TestRuntimeSafetyEnvironmentDisablesStackShrinkingBeforeStartup(t *testing.T) { | |||
| 18 | + | tests := []struct { | |||
| 19 | + | name string | |||
| 20 | + | environ []string | |||
| 21 | + | wantGODEBUG string | |||
| 22 | + | wantReexec bool | |||
| 23 | + | }{ | |||
| 24 | + | {name: "missing", environ: []string{"HOME=/tmp"}, wantGODEBUG: "gcshrinkstackoff=1", wantReexec: true}, | |||
| 25 | + | {name: "preserves settings", environ: []string{"GODEBUG=gctrace=1"}, wantGODEBUG: "gctrace=1,gcshrinkstackoff=1", wantReexec: true}, | |||
| 26 | + | {name: "overrides unsafe value", environ: []string{"GODEBUG=gcshrinkstackoff=0,gctrace=1"}, wantGODEBUG: "gctrace=1,gcshrinkstackoff=1", wantReexec: true}, | |||
| 27 | + | {name: "already safe", environ: []string{"GODEBUG=gctrace=1,gcshrinkstackoff=1"}, wantGODEBUG: "gctrace=1,gcshrinkstackoff=1", wantReexec: false}, | |||
| 28 | + | {name: "forged marker is ignored", environ: []string{"DUMBER_RUNTIME_SAFETY_REEXEC=1", "GODEBUG=gcshrinkstackoff=0"}, wantGODEBUG: "gcshrinkstackoff=1", wantReexec: true}, | |||
| 29 | + | } | |||
| 30 | + | for _, tt := range tests { | |||
| 31 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 32 | + | got, reexec := runtimeSafetyEnvironment(tt.environ) | |||
| 33 | + | if reexec != tt.wantReexec { | |||
| 34 | + | t.Fatalf("reexec = %v, want %v", reexec, tt.wantReexec) | |||
| 35 | + | } | |||
| 36 | + | if value := environmentValue(got, "GODEBUG"); value != tt.wantGODEBUG { | |||
| 37 | + | t.Fatalf("GODEBUG = %q, want %q", value, tt.wantGODEBUG) | |||
| 38 | + | } | |||
| 39 | + | }) | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 17 | func TestLaunchModeFromArgs_DetectsStandaloneOmnibox(t *testing.T) { | 43 | func TestLaunchModeFromArgs_DetectsStandaloneOmnibox(t *testing.T) { | ||
| 18 | mode, _ := launchModeFromArgs([]string{"dumber", "omnibox"}) | 44 | mode, _ := launchModeFromArgs([]string{"dumber", "omnibox"}) | ||
| 19 | if mode != launchModeStandaloneOmnibox { | 45 | if mode != launchModeStandaloneOmnibox { | ||