From 809facb62752acc36fcc7bd220743d3551435af2 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 27 Aug 2026 09:54:18 +0200 Subject: [PATCH] Let the caller say how long a page may be drawn for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Options.MaxPixels bounds what comes out of a page. Nothing bounded the work of making it, so a caller drawing somebody else's file had no way to say how long it was prepared to wait — and some pages take a very long time. Measured over 59 432 corpus pages at half again their own size: 1 131 were still being drawn after twenty seconds, and one figure took two hundred and seventy-three. Two hours of measuring this corpus ended with every worker but one finished and the last grinding on a single page with fifteen gigabytes resident. A page that cannot be given up on is a page that can take a machine away from whoever was using it. Options.MaxDuration is how long it may have. Zero means as long as it takes, which is what this did before there was anywhere to say otherwise, so nothing already written changes. When the time goes, the page stops being drawn and comes back as far as it got, with ErrTimedOut — deliberately not a blank, since half a page is worth more than none to somebody scrolling, and the error says plainly that it is half. The clock is looked at once every 256 operations rather than on every one: asking the machine the time is dear beside drawing a line. A page of a hundred thousand operations therefore asks four hundred times. It costs nothing. Over 3 000 corpus pages, with no budget set: 1:07 against 1:14 without the code at all — the difference is noise, and the wrong way round. Of the 2 991 pages both runs drew, not one hash differs. --- budget_test.go | 101 +++++++++++++++++++++++++++++++++++++++++++++++++ exec.go | 3 ++ render.go | 29 ++++++++++++++ state.go | 37 ++++++++++++++++++ 4 files changed, 170 insertions(+) create mode 100644 budget_test.go diff --git a/budget_test.go b/budget_test.go new file mode 100644 index 0000000..5fc9399 --- /dev/null +++ b/budget_test.go @@ -0,0 +1,101 @@ +package render + +import ( + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/go-pdfkit/reader" +) + +// slowPage builds a page that asks for a great deal of drawing: many thousands +// of filled rectangles, which is what a plotting tool writes and what takes a +// renderer a long time. +func slowPage(t *testing.T, rects int) *reader.Document { + t.Helper() + var content strings.Builder + for i := 0; i < rects; i++ { + fmt.Fprintf(&content, "%d %d 40 40 re f\n", i%60, (i*7)%60) + } + return shadedPage(t, content.String(), func(w *reader.Writer) reader.Dict { + return reader.Dict{} + }) +} + +func TestAPageMayBeGivenOnlySoLong(t *testing.T) { + // Some pages take a very long time, and a caller drawing somebody else's + // file cannot afford to wait for the worst of them. What comes back is + // how far it got, and an error saying so. + d := slowPage(t, 400000) + start := time.Now() + img, err := Page(d, 1, Options{Scale: 1, MaxDuration: 50 * time.Millisecond}) + took := time.Since(start) + if !errors.Is(err, ErrTimedOut) { + t.Fatalf("a page given fifty milliseconds came back with %v after %s", err, took) + } + if img == nil { + t.Fatal("nothing came back at all; half a page is worth more than none") + } + if img.W == 0 || img.H == 0 { + t.Fatalf("what came back is %dx%d", img.W, img.H) + } + // It has to stop near when it was told to, not merely eventually. + if took > 5*time.Second { + t.Errorf("it took %s to give up on fifty milliseconds", took) + } +} + +func TestAPageGivenNoLimitIsDrawnWhole(t *testing.T) { + // Zero means as long as it takes, which is what this did before there was + // anywhere to say otherwise. + d := slowPage(t, 200) + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + if isWhite(img, 20, 30) { + t.Error("the page came back blank") + } +} + +func TestAPageThatFinishesInTimeSaysNothingAboutIt(t *testing.T) { + d := slowPage(t, 50) + img, err := Page(d, 1, Options{Scale: 1, MaxDuration: time.Minute}) + if err != nil { + t.Fatalf("a page with a minute to draw in came back with %v", err) + } + if isWhite(img, 20, 30) { + t.Error("the page came back blank") + } +} + +func TestLookingAtTheClock(t *testing.T) { + // The clock is looked at once every so many operations, because asking the + // machine the time is dear beside drawing a line. Once the time has gone, + // it is gone: the answer does not depend on being asked again. + r := &renderer{} + if r.overrun() { + t.Error("a page with no deadline said it had run out of time") + } + r.deadline = time.Now().Add(-time.Second) + for i := 0; i < timeCheckEvery-1; i++ { + if r.overrun() { + t.Fatalf("it looked at the clock after %d operations, not %d", i+1, timeCheckEvery) + } + } + if !r.overrun() { + t.Fatal("it never looked at the clock at all") + } + if !r.overrun() { + t.Error("having run out of time, it changed its mind") + } + + fresh := &renderer{deadline: time.Now().Add(time.Hour)} + for i := 0; i < timeCheckEvery+2; i++ { + if fresh.overrun() { + t.Fatal("a page with an hour to draw in said its time had gone") + } + } +} diff --git a/exec.go b/exec.go index 9310afc..1fcfad6 100644 --- a/exec.go +++ b/exec.go @@ -31,6 +31,9 @@ func (r *renderer) run(content []byte, resources reader.Dict, g gstate) { if r.ops++; r.ops > maxOperations { return } + if r.overrun() { + return + } n := numbers(op.Operands) switch op.Operator { case "q": diff --git a/render.go b/render.go index 59b29f0..a78b560 100644 --- a/render.go +++ b/render.go @@ -8,8 +8,10 @@ package render import ( + "errors" "fmt" "math" + "time" "github.com/go-gfx/gfx/geometry" "github.com/go-gfx/gfx/raster" @@ -36,8 +38,29 @@ type Options struct { // drawn by default, because a filled-in form drawn without them is a form // with nothing in it. NoAnnotations bool + // MaxDuration is how long the page may be drawn for. Zero means as long as + // it takes, which is what this did before there was anywhere to say + // otherwise. + // + // Some pages take a very long time. Of 59 432 corpus pages drawn at half + // as much again as their own size, 1 131 were still going after twenty + // seconds, and one figure took two hundred and seventy-three. A caller + // drawing somebody else's file — a browser tab, a queue of them, a server + // — cannot afford to wait for the worst of those and has, until now, had + // no way to say so: MaxPixels bounds what comes out, and nothing bounded + // the work of making it. + // + // When the time runs out the page stops being drawn and comes back as far + // as it got, with ErrTimedOut. That is deliberately not a blank: half a + // page is worth more than nothing to somebody scrolling, and the error + // says plainly that it is half. + MaxDuration time.Duration } +// ErrTimedOut says a page was still being drawn when its time ran out. The +// image returned with it holds what had been drawn by then. +var ErrTimedOut = errors.New("render: the page was still being drawn when its time ran out") + // defaultMaxPixels is a little more than A4 at 600 dots to the inch. const defaultMaxPixels = 40 << 20 @@ -96,12 +119,18 @@ func Page(d *reader.Document, i int, opt Options) (*raster.Image, error) { } resources, _ := d.GetDict(page, "Resources") r := &renderer{doc: d, img: img, fonts: map[int]*pdfFont{}, softMasks: map[softMaskKey][]uint8{}} + if opt.MaxDuration > 0 { + r.deadline = time.Now().Add(opt.MaxDuration) + } start := r.initialState(box, rotation, s) r.base = start.ctm r.run(content, resources, start) if !opt.NoAnnotations { r.drawAnnotations(page, start) } + if r.ranOut { + return img, ErrTimedOut + } return img, nil } diff --git a/state.go b/state.go index 4302443..6713e39 100644 --- a/state.go +++ b/state.go @@ -7,6 +7,7 @@ import ( "github.com/go-pdfkit/reader" "image" "image/color" + "time" ) // A gstate is everything the drawing operators read and write: where things @@ -98,6 +99,42 @@ type renderer struct { // ops counts what has been drawn, so a file cannot ask for an unbounded // amount of work. ops int + + // deadline is when this page stops being drawn, or the zero time when it + // may take as long as it takes. checked is how many operations ago the + // clock was last looked at, since asking the machine the time is dear + // beside drawing a line. + deadline time.Time + checked int + // ranOut records that the time passed, so that what comes back can say + // the page is unfinished rather than pretend it is done. + ranOut bool +} + +// timeCheckEvery is how many operations pass between looks at the clock. A +// page of a hundred thousand operations then asks the machine the time four +// hundred times, which costs nothing measurable; asking on every operation +// would cost more than some of the operations do. +const timeCheckEvery = 256 + +// overrun says whether the page has been drawing for longer than it was given. +// It is asked once an operation and looks at the clock far less often. +func (r *renderer) overrun() bool { + if r.deadline.IsZero() { + return false + } + if r.ranOut { + return true + } + if r.checked++; r.checked < timeCheckEvery { + return false + } + r.checked = 0 + if time.Now().After(r.deadline) { + r.ranOut = true + return true + } + return false } // maxFormDepth is how deeply forms may nest before the page gives up.