perf(headless): encode each screenshot frame once with fast PNG compression
- commit
- 0eeac95b8149301ee569458a4584cfed7811fd39
- author
- brice · · 2026-10-21 17:54 UTC
- committer
- brice · · 2026-10-22 04:47 UTC
- parent
- 3a0a985
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +147 -2
M internal/adapters/headless/output.go +44 -2
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "image/png" | |
| 9 | 9 | "os" | |
| 10 | 10 | "path/filepath" | |
| 11 | + | "sync" | |
| 11 | 12 | "time" | |
| 12 | 13 | ||
| 13 | 14 | "golang.org/x/sys/unix" | |
| @@ -583,7 +584,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 583 | 584 | if !written { | |
| 584 | 585 | continue | |
| 585 | 586 | } | |
| 586 | - | written, err = writePNGSecureResult(filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security) | |
| 587 | + | // latest.png is the frame just written, linked rather than encoded again. | |
| 588 | + | written, err = linkPNGSecure(filepath.Join(opts.ScreenshotDir, name), filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security) | |
| 587 | 589 | if err != nil { | |
| 588 | 590 | return fmt.Errorf("latest screenshot: %w", err) | |
| 589 | 591 | } | |
| @@ -640,6 +642,21 @@ func writePNGSecure(path string, img image.Image, security ports.SessionSecurity | |||
| 640 | 642 | return err | |
| 641 | 643 | } | |
| 642 | 644 | ||
| 645 | + | // screenshotPNG encodes screenshot frames: encoding is by far the largest cost | |
| 646 | + | // of a headless frame, so it trades file size for speed. Its buffers are | |
| 647 | + | // reused between frames. | |
| 648 | + | var screenshotPNG = png.Encoder{CompressionLevel: png.BestSpeed, BufferPool: new(pngBuffers)} | |
| 649 | + | ||
| 650 | + | // pngBuffers is a png.EncoderBufferPool. | |
| 651 | + | type pngBuffers struct{ pool sync.Pool } | |
| 652 | + | ||
| 653 | + | func (p *pngBuffers) Get() *png.EncoderBuffer { | |
| 654 | + | b, _ := p.pool.Get().(*png.EncoderBuffer) | |
| 655 | + | return b | |
| 656 | + | } | |
| 657 | + | ||
| 658 | + | func (p *pngBuffers) Put(b *png.EncoderBuffer) { p.pool.Put(b) } | |
| 659 | + | ||
| 643 | 660 | // writePNGSecureResult distinguishes a security skip from a published PNG. | |
| 644 | 661 | func writePNGSecureResult(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) (bool, error) { | |
| 645 | 662 | if security != nil && (state.Protected || security.Snapshot() != state) { | |
| @@ -650,7 +667,7 @@ func writePNGSecureResult(path string, img image.Image, security ports.SessionSe | |||
| 650 | 667 | return false, err | |
| 651 | 668 | } | |
| 652 | 669 | defer os.Remove(f.Name()) | |
| 653 | - | if err := png.Encode(f, img); err != nil { | |
| 670 | + | if err := screenshotPNG.Encode(f, img); err != nil { | |
| 654 | 671 | _ = f.Close() | |
| 655 | 672 | return false, err | |
| 656 | 673 | } | |
| @@ -666,6 +683,31 @@ func writePNGSecureResult(path string, img image.Image, security ports.SessionSe | |||
| 666 | 683 | return true, nil | |
| 667 | 684 | } | |
| 668 | 685 | ||
| 686 | + | // linkPNGSecure publishes the PNG already written at src as dst without | |
| 687 | + | // encoding it again: a hard link under a temporary name, renamed over dst, so | |
| 688 | + | // readers see the old or the new frame, never a partial one. Frame files are | |
| 689 | + | // never modified after they are published, which makes the shared file safe. | |
| 690 | + | // It gates on security like writePNGSecureResult, and falls back to encoding | |
| 691 | + | // img where the file system cannot link. | |
| 692 | + | func linkPNGSecure(src, dst string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) (bool, error) { | |
| 693 | + | if security != nil && (state.Protected || security.Snapshot() != state) { | |
| 694 | + | return false, nil | |
| 695 | + | } | |
| 696 | + | tmp := dst + ".tmp" | |
| 697 | + | _ = os.Remove(tmp) | |
| 698 | + | if err := os.Link(src, tmp); err != nil { | |
| 699 | + | return writePNGSecureResult(dst, img, security, state) | |
| 700 | + | } | |
| 701 | + | defer os.Remove(tmp) | |
| 702 | + | if security != nil && security.Snapshot() != state { | |
| 703 | + | return false, nil | |
| 704 | + | } | |
| 705 | + | if err := os.Rename(tmp, dst); err != nil { | |
| 706 | + | return false, err | |
| 707 | + | } | |
| 708 | + | return true, nil | |
| 709 | + | } | |
| 710 | + | ||
| 669 | 711 | // report queues what the output read, with flip for a drawn frame, and | |
| 670 | 712 | // pushes it to the sink. | |
| 671 | 713 | func (opts Options) report(q *presented.Queue, flip *ports.FlipInfo, seen map[ports.WindowID]uint64) { | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "image/png" | 8 | "image/png" | ||
| 9 | "os" | 9 | "os" | ||
| 10 | "path/filepath" | 10 | "path/filepath" | ||
| 11 | + | "sync" | |||
| 11 | "time" | 12 | "time" | ||
| 12 | 13 | ||||
| 13 | "golang.org/x/sys/unix" | 14 | "golang.org/x/sys/unix" | ||
| @@ -583,7 +584,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 583 | if !written { | 584 | if !written { | ||
| 584 | continue | 585 | continue | ||
| 585 | } | 586 | } | ||
| 586 | - | written, err = writePNGSecureResult(filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security) | 587 | + | // latest.png is the frame just written, linked rather than encoded again. |
| 588 | + | written, err = linkPNGSecure(filepath.Join(opts.ScreenshotDir, name), filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security) | |||
| 587 | if err != nil { | 589 | if err != nil { | ||
| 588 | return fmt.Errorf("latest screenshot: %w", err) | 590 | return fmt.Errorf("latest screenshot: %w", err) | ||
| 589 | } | 591 | } | ||
| @@ -640,6 +642,21 @@ func writePNGSecure(path string, img image.Image, security ports.SessionSecurity | |||||
| 640 | return err | 642 | return err | ||
| 641 | } | 643 | } | ||
| 642 | 644 | ||||
| 645 | + | // screenshotPNG encodes screenshot frames: encoding is by far the largest cost | |||
| 646 | + | // of a headless frame, so it trades file size for speed. Its buffers are | |||
| 647 | + | // reused between frames. | |||
| 648 | + | var screenshotPNG = png.Encoder{CompressionLevel: png.BestSpeed, BufferPool: new(pngBuffers)} | |||
| 649 | + | ||||
| 650 | + | // pngBuffers is a png.EncoderBufferPool. | |||
| 651 | + | type pngBuffers struct{ pool sync.Pool } | |||
| 652 | + | ||||
| 653 | + | func (p *pngBuffers) Get() *png.EncoderBuffer { | |||
| 654 | + | b, _ := p.pool.Get().(*png.EncoderBuffer) | |||
| 655 | + | return b | |||
| 656 | + | } | |||
| 657 | + | ||||
| 658 | + | func (p *pngBuffers) Put(b *png.EncoderBuffer) { p.pool.Put(b) } | |||
| 659 | + | ||||
| 643 | // writePNGSecureResult distinguishes a security skip from a published PNG. | 660 | // writePNGSecureResult distinguishes a security skip from a published PNG. | ||
| 644 | func writePNGSecureResult(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) (bool, error) { | 661 | func writePNGSecureResult(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) (bool, error) { | ||
| 645 | if security != nil && (state.Protected || security.Snapshot() != state) { | 662 | if security != nil && (state.Protected || security.Snapshot() != state) { | ||
| @@ -650,7 +667,7 @@ func writePNGSecureResult(path string, img image.Image, security ports.SessionSe | |||||
| 650 | return false, err | 667 | return false, err | ||
| 651 | } | 668 | } | ||
| 652 | defer os.Remove(f.Name()) | 669 | defer os.Remove(f.Name()) | ||
| 653 | - | if err := png.Encode(f, img); err != nil { | 670 | + | if err := screenshotPNG.Encode(f, img); err != nil { |
| 654 | _ = f.Close() | 671 | _ = f.Close() | ||
| 655 | return false, err | 672 | return false, err | ||
| 656 | } | 673 | } | ||
| @@ -666,6 +683,31 @@ func writePNGSecureResult(path string, img image.Image, security ports.SessionSe | |||||
| 666 | return true, nil | 683 | return true, nil | ||
| 667 | } | 684 | } | ||
| 668 | 685 | ||||
| 686 | + | // linkPNGSecure publishes the PNG already written at src as dst without | |||
| 687 | + | // encoding it again: a hard link under a temporary name, renamed over dst, so | |||
| 688 | + | // readers see the old or the new frame, never a partial one. Frame files are | |||
| 689 | + | // never modified after they are published, which makes the shared file safe. | |||
| 690 | + | // It gates on security like writePNGSecureResult, and falls back to encoding | |||
| 691 | + | // img where the file system cannot link. | |||
| 692 | + | func linkPNGSecure(src, dst string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) (bool, error) { | |||
| 693 | + | if security != nil && (state.Protected || security.Snapshot() != state) { | |||
| 694 | + | return false, nil | |||
| 695 | + | } | |||
| 696 | + | tmp := dst + ".tmp" | |||
| 697 | + | _ = os.Remove(tmp) | |||
| 698 | + | if err := os.Link(src, tmp); err != nil { | |||
| 699 | + | return writePNGSecureResult(dst, img, security, state) | |||
| 700 | + | } | |||
| 701 | + | defer os.Remove(tmp) | |||
| 702 | + | if security != nil && security.Snapshot() != state { | |||
| 703 | + | return false, nil | |||
| 704 | + | } | |||
| 705 | + | if err := os.Rename(tmp, dst); err != nil { | |||
| 706 | + | return false, err | |||
| 707 | + | } | |||
| 708 | + | return true, nil | |||
| 709 | + | } | |||
| 710 | + | ||||
| 669 | // report queues what the output read, with flip for a drawn frame, and | 711 | // report queues what the output read, with flip for a drawn frame, and | ||
| 670 | // pushes it to the sink. | 712 | // pushes it to the sink. | ||
| 671 | func (opts Options) report(q *presented.Queue, flip *ports.FlipInfo, seen map[ports.WindowID]uint64) { | 713 | func (opts Options) report(q *presented.Queue, flip *ports.FlipInfo, seen map[ports.WindowID]uint64) { | ||
M internal/adapters/headless/output_test.go +103 -0
| @@ -269,6 +269,109 @@ func TestRunTrimsRendererOnTick(t *testing.T) { | |||
| 269 | 269 | } | |
| 270 | 270 | } | |
| 271 | 271 | ||
| 272 | + | // latest.png is the frame file linked, not encoded again: the same pixels, | |
| 273 | + | // published atomically, and nothing left behind. | |
| 274 | + | func TestLinkPNGSecure(t *testing.T) { | |
| 275 | + | dir := t.TempDir() | |
| 276 | + | img := image.NewRGBA(image.Rect(0, 0, 3, 2)) | |
| 277 | + | for i := range img.Pix { | |
| 278 | + | img.Pix[i] = byte(i * 7) | |
| 279 | + | } | |
| 280 | + | frame, latest := filepath.Join(dir, "frame-000001.png"), filepath.Join(dir, "latest.png") | |
| 281 | + | if err := writePNGSecure(frame, img, nil, ports.SecurityState{}); err != nil { | |
| 282 | + | t.Fatal(err) | |
| 283 | + | } | |
| 284 | + | // An older latest.png is replaced. | |
| 285 | + | if err := os.WriteFile(latest, []byte("old"), 0o600); err != nil { | |
| 286 | + | t.Fatal(err) | |
| 287 | + | } | |
| 288 | + | if written, err := linkPNGSecure(frame, latest, img, nil, ports.SecurityState{}); err != nil || !written { | |
| 289 | + | t.Fatalf("link = %v, %v", written, err) | |
| 290 | + | } | |
| 291 | + | a, err := os.ReadFile(frame) | |
| 292 | + | if err != nil { | |
| 293 | + | t.Fatal(err) | |
| 294 | + | } | |
| 295 | + | b, err := os.ReadFile(latest) | |
| 296 | + | if err != nil { | |
| 297 | + | t.Fatal(err) | |
| 298 | + | } | |
| 299 | + | if !bytes.Equal(a, b) { | |
| 300 | + | t.Fatal("latest.png differs from the frame") | |
| 301 | + | } | |
| 302 | + | got, err := png.Decode(bytes.NewReader(b)) | |
| 303 | + | if err != nil { | |
| 304 | + | t.Fatal(err) | |
| 305 | + | } | |
| 306 | + | for y := range 2 { | |
| 307 | + | for x := range 3 { | |
| 308 | + | if g, w := color.RGBAModel.Convert(got.At(x, y)), img.RGBAAt(x, y); g != color.Color(w) { | |
| 309 | + | t.Errorf("(%d,%d) = %v, want %v", x, y, g, w) | |
| 310 | + | } | |
| 311 | + | } | |
| 312 | + | } | |
| 313 | + | entries, err := os.ReadDir(dir) | |
| 314 | + | if err != nil { | |
| 315 | + | t.Fatal(err) | |
| 316 | + | } | |
| 317 | + | if len(entries) != 2 { | |
| 318 | + | t.Fatalf("leftover files: %v", entries) | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | // A frame that crossed a security change is not published as latest.png, and | |
| 323 | + | // a source that cannot be linked is encoded instead. | |
| 324 | + | func TestLinkPNGSecureSkipsAndFallsBack(t *testing.T) { | |
| 325 | + | img := image.NewRGBA(image.Rect(0, 0, 1, 1)) | |
| 326 | + | good := ports.SecurityState{Generation: 1} | |
| 327 | + | t.Run("protected", func(t *testing.T) { | |
| 328 | + | dir := t.TempDir() | |
| 329 | + | frame := filepath.Join(dir, "frame-000001.png") | |
| 330 | + | if err := writePNGSecure(frame, img, nil, ports.SecurityState{}); err != nil { | |
| 331 | + | t.Fatal(err) | |
| 332 | + | } | |
| 333 | + | gate := portsmocks.NewMockSessionSecurity(t) | |
| 334 | + | gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true}) | |
| 335 | + | if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, good); err != nil || written { | |
| 336 | + | t.Fatalf("link = %v, %v", written, err) | |
| 337 | + | } | |
| 338 | + | if entries, _ := os.ReadDir(dir); len(entries) != 1 { | |
| 339 | + | t.Fatalf("latest.png or a temp file delivered: %v", entries) | |
| 340 | + | } | |
| 341 | + | }) | |
| 342 | + | t.Run("changed after the link", func(t *testing.T) { | |
| 343 | + | dir := t.TempDir() | |
| 344 | + | frame := filepath.Join(dir, "frame-000001.png") | |
| 345 | + | if err := writePNGSecure(frame, img, nil, ports.SecurityState{}); err != nil { | |
| 346 | + | t.Fatal(err) | |
| 347 | + | } | |
| 348 | + | gate := portsmocks.NewMockSessionSecurity(t) | |
| 349 | + | gate.EXPECT().Snapshot().Return(good).Once() | |
| 350 | + | gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 2}).Once() | |
| 351 | + | if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, good); err != nil || written { | |
| 352 | + | t.Fatalf("link = %v, %v", written, err) | |
| 353 | + | } | |
| 354 | + | if entries, _ := os.ReadDir(dir); len(entries) != 1 { | |
| 355 | + | t.Fatalf("latest.png or a temp file delivered: %v", entries) | |
| 356 | + | } | |
| 357 | + | }) | |
| 358 | + | t.Run("no source file", func(t *testing.T) { | |
| 359 | + | dir := t.TempDir() | |
| 360 | + | latest := filepath.Join(dir, "latest.png") | |
| 361 | + | if written, err := linkPNGSecure(filepath.Join(dir, "missing.png"), latest, img, nil, ports.SecurityState{}); err != nil || !written { | |
| 362 | + | t.Fatalf("link = %v, %v", written, err) | |
| 363 | + | } | |
| 364 | + | f, err := os.Open(latest) | |
| 365 | + | if err != nil { | |
| 366 | + | t.Fatal(err) | |
| 367 | + | } | |
| 368 | + | defer f.Close() | |
| 369 | + | if _, err := png.Decode(f); err != nil { | |
| 370 | + | t.Fatal(err) | |
| 371 | + | } | |
| 372 | + | }) | |
| 373 | + | } | |
| 374 | + | ||
| 272 | 375 | func TestWritePNGAtomic(t *testing.T) { | |
| 273 | 376 | dir := t.TempDir() | |
| 274 | 377 | img := image.NewRGBA(image.Rect(0, 0, 1, 1)) | |
| @@ -269,6 +269,109 @@ func TestRunTrimsRendererOnTick(t *testing.T) { | |||||
| 269 | } | 269 | } | ||
| 270 | } | 270 | } | ||
| 271 | 271 | ||||
| 272 | + | // latest.png is the frame file linked, not encoded again: the same pixels, | |||
| 273 | + | // published atomically, and nothing left behind. | |||
| 274 | + | func TestLinkPNGSecure(t *testing.T) { | |||
| 275 | + | dir := t.TempDir() | |||
| 276 | + | img := image.NewRGBA(image.Rect(0, 0, 3, 2)) | |||
| 277 | + | for i := range img.Pix { | |||
| 278 | + | img.Pix[i] = byte(i * 7) | |||
| 279 | + | } | |||
| 280 | + | frame, latest := filepath.Join(dir, "frame-000001.png"), filepath.Join(dir, "latest.png") | |||
| 281 | + | if err := writePNGSecure(frame, img, nil, ports.SecurityState{}); err != nil { | |||
| 282 | + | t.Fatal(err) | |||
| 283 | + | } | |||
| 284 | + | // An older latest.png is replaced. | |||
| 285 | + | if err := os.WriteFile(latest, []byte("old"), 0o600); err != nil { | |||
| 286 | + | t.Fatal(err) | |||
| 287 | + | } | |||
| 288 | + | if written, err := linkPNGSecure(frame, latest, img, nil, ports.SecurityState{}); err != nil || !written { | |||
| 289 | + | t.Fatalf("link = %v, %v", written, err) | |||
| 290 | + | } | |||
| 291 | + | a, err := os.ReadFile(frame) | |||
| 292 | + | if err != nil { | |||
| 293 | + | t.Fatal(err) | |||
| 294 | + | } | |||
| 295 | + | b, err := os.ReadFile(latest) | |||
| 296 | + | if err != nil { | |||
| 297 | + | t.Fatal(err) | |||
| 298 | + | } | |||
| 299 | + | if !bytes.Equal(a, b) { | |||
| 300 | + | t.Fatal("latest.png differs from the frame") | |||
| 301 | + | } | |||
| 302 | + | got, err := png.Decode(bytes.NewReader(b)) | |||
| 303 | + | if err != nil { | |||
| 304 | + | t.Fatal(err) | |||
| 305 | + | } | |||
| 306 | + | for y := range 2 { | |||
| 307 | + | for x := range 3 { | |||
| 308 | + | if g, w := color.RGBAModel.Convert(got.At(x, y)), img.RGBAAt(x, y); g != color.Color(w) { | |||
| 309 | + | t.Errorf("(%d,%d) = %v, want %v", x, y, g, w) | |||
| 310 | + | } | |||
| 311 | + | } | |||
| 312 | + | } | |||
| 313 | + | entries, err := os.ReadDir(dir) | |||
| 314 | + | if err != nil { | |||
| 315 | + | t.Fatal(err) | |||
| 316 | + | } | |||
| 317 | + | if len(entries) != 2 { | |||
| 318 | + | t.Fatalf("leftover files: %v", entries) | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 321 | + | ||||
| 322 | + | // A frame that crossed a security change is not published as latest.png, and | |||
| 323 | + | // a source that cannot be linked is encoded instead. | |||
| 324 | + | func TestLinkPNGSecureSkipsAndFallsBack(t *testing.T) { | |||
| 325 | + | img := image.NewRGBA(image.Rect(0, 0, 1, 1)) | |||
| 326 | + | good := ports.SecurityState{Generation: 1} | |||
| 327 | + | t.Run("protected", func(t *testing.T) { | |||
| 328 | + | dir := t.TempDir() | |||
| 329 | + | frame := filepath.Join(dir, "frame-000001.png") | |||
| 330 | + | if err := writePNGSecure(frame, img, nil, ports.SecurityState{}); err != nil { | |||
| 331 | + | t.Fatal(err) | |||
| 332 | + | } | |||
| 333 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 334 | + | gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true}) | |||
| 335 | + | if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, good); err != nil || written { | |||
| 336 | + | t.Fatalf("link = %v, %v", written, err) | |||
| 337 | + | } | |||
| 338 | + | if entries, _ := os.ReadDir(dir); len(entries) != 1 { | |||
| 339 | + | t.Fatalf("latest.png or a temp file delivered: %v", entries) | |||
| 340 | + | } | |||
| 341 | + | }) | |||
| 342 | + | t.Run("changed after the link", func(t *testing.T) { | |||
| 343 | + | dir := t.TempDir() | |||
| 344 | + | frame := filepath.Join(dir, "frame-000001.png") | |||
| 345 | + | if err := writePNGSecure(frame, img, nil, ports.SecurityState{}); err != nil { | |||
| 346 | + | t.Fatal(err) | |||
| 347 | + | } | |||
| 348 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 349 | + | gate.EXPECT().Snapshot().Return(good).Once() | |||
| 350 | + | gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 2}).Once() | |||
| 351 | + | if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, good); err != nil || written { | |||
| 352 | + | t.Fatalf("link = %v, %v", written, err) | |||
| 353 | + | } | |||
| 354 | + | if entries, _ := os.ReadDir(dir); len(entries) != 1 { | |||
| 355 | + | t.Fatalf("latest.png or a temp file delivered: %v", entries) | |||
| 356 | + | } | |||
| 357 | + | }) | |||
| 358 | + | t.Run("no source file", func(t *testing.T) { | |||
| 359 | + | dir := t.TempDir() | |||
| 360 | + | latest := filepath.Join(dir, "latest.png") | |||
| 361 | + | if written, err := linkPNGSecure(filepath.Join(dir, "missing.png"), latest, img, nil, ports.SecurityState{}); err != nil || !written { | |||
| 362 | + | t.Fatalf("link = %v, %v", written, err) | |||
| 363 | + | } | |||
| 364 | + | f, err := os.Open(latest) | |||
| 365 | + | if err != nil { | |||
| 366 | + | t.Fatal(err) | |||
| 367 | + | } | |||
| 368 | + | defer f.Close() | |||
| 369 | + | if _, err := png.Decode(f); err != nil { | |||
| 370 | + | t.Fatal(err) | |||
| 371 | + | } | |||
| 372 | + | }) | |||
| 373 | + | } | |||
| 374 | + | ||||
| 272 | func TestWritePNGAtomic(t *testing.T) { | 375 | func TestWritePNGAtomic(t *testing.T) { | ||
| 273 | dir := t.TempDir() | 376 | dir := t.TempDir() | ||
| 274 | img := image.NewRGBA(image.Rect(0, 0, 1, 1)) | 377 | img := image.NewRGBA(image.Rect(0, 0, 1, 1)) | ||