Repository navigation
Expand file tree
/
Copy pathcapped.go
More file actions
343 lines (318 loc) · 10.8 KB
/
Copy pathcapped.go
File metadata and controls
343 lines (318 loc) · 10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
package exec
import (
"bytes"
"io"
)
// cappedOutput collects a subprocess's merged output in the shape the result is
// going to take anyway.
//
// The old path read the whole of it into memory and then threw all but twelve
// kilobytes away. `go test ./...` on a large tree, a verbose build, a `find /`
// that went wrong — each is hundreds of megabytes, held twice while the string
// conversion is made, inside a call that is allowed to run for fifteen minutes,
// once per concurrent leaf. Nothing downstream ever sees those bytes.
//
// So this keeps exactly what the result bound keeps — a ring holding the tail,
// the counts a truncation notice has to state, and a file on the side holding
// everything — and renders what boundResult would have rendered from the whole.
// Memory is bounded at the carry limit no matter how much the command prints.
//
// The tail is what is kept because this is COMMAND output, and a command's most
// valuable line is its last: the compiler's error, the test summary, the
// traceback, the exit reason. The head is not lost, it is moved — the moment
// the output outgrows what can be carried, the collector opens the spill file
// and everything goes there, so the notice can hand the model the exact command
// that reads back the part that was cut.
type cappedOutput struct {
// carry is the largest output that is worth putting in context whole, and
// keep is what survives when it is not. Both are the consuming leaf's, from
// toolBudgets: a model with a larger working set keeps proportionally more
// of what its commands printed.
carry int
keep int
ring []byte // the last carry bytes, oldest at next
next int
round bool
total int
newlines int
endsNewline bool
// The full output, teed as it arrives. open is called at most once, on the
// first byte past carry — a command whose output fits needs no file, and
// most commands' output fits.
open func() (io.WriteCloser, string, bool)
file io.WriteCloser
spillPath string
written int
partial bool
teeFailed bool
// drop decides, from the beginning of a line, whether that line is one the
// caller would have removed afterwards. It is how rtk's nudge stripping
// survives being done a line at a time — see cappedLineDecision.
drop func([]byte) bool
pending []byte
state lineState
preceded bool
finished bool
}
type lineState int
const (
lineUndecided lineState = iota
lineKept
lineDropped
)
// cappedLineDecision is how much of a line is held while deciding whether to
// keep it. The only decision made here is rtk's, and rtk's is a prefix test on
// a short marker, so the beginning of a line settles it and a line of any
// length is passed through without being held.
const cappedLineDecision = 64
// spillFileMultiple bounds the file the whole output is teed to, as a multiple
// of what the leaf could have carried.
//
// Disk is not context, and the file exists precisely so that truncation is
// recoverable rather than lossy, so the multiple is generous — two megabytes of
// build log on a 200k-token leaf. It is not unbounded because a command may
// print at line rate for two minutes, and a workspace that filled a disk would
// be a worse failure than a log that stops. When it stops, the notice says so
// and the file still holds the beginning, which is the half the context does
// not have.
const spillFileMultiple = 64
// newCappedOutput returns a collector bounded at carry, which must be the same
// carry the result bound uses — the writer keeps exactly what boundResult
// keeps, and a mismatch would render something boundResult would not have
// rendered. keep is what survives a truncation and must be smaller than carry,
// so that the whole of the kept region is inside the ring. drop may be nil,
// which keeps every line — and keeping every line is byte-for-byte the same as
// not filtering at all, because the filter reassembles the lines it kept with
// the separators that were between them. open may be nil, which is a collector
// with nowhere to spill: it still bounds memory, and its notice says the rest
// was not saved.
func newCappedOutput(drop func([]byte) bool, carry, keep int, open func() (io.WriteCloser, string, bool)) *cappedOutput {
if carry <= 0 {
carry = maxToolResultBytes
}
if keep <= 0 || keep >= carry {
keep = carry / 2
}
return &cappedOutput{carry: carry, keep: keep, ring: make([]byte, carry), drop: drop, open: open}
}
// Write takes the child's output as it arrives. One collector is used for both
// stdout and stderr — the same value, so os/exec gives the child a single pipe
// and the two streams merge in the child exactly as CombinedOutput merged them.
func (c *cappedOutput) Write(p []byte) (int, error) {
written := len(p)
if c.drop == nil {
c.keepBytes(p)
return written, nil
}
for len(p) > 0 {
piece := p
terminated := false
if index := bytes.IndexByte(p, '\n'); index >= 0 {
piece, p, terminated = p[:index], p[index+1:], true
} else {
p = nil
}
c.line(piece)
if terminated {
c.endLine()
}
}
return written, nil
}
// line takes one line's bytes, up to but not including its newline.
func (c *cappedOutput) line(piece []byte) {
switch c.state {
case lineDropped:
return
case lineKept:
c.keepBytes(piece)
return
}
if room := cappedLineDecision - len(c.pending); room > 0 {
if room > len(piece) {
room = len(piece)
}
c.pending = append(c.pending, piece[:room]...)
piece = piece[room:]
}
if len(c.pending) < cappedLineDecision {
return
}
c.decide()
if c.state == lineKept {
c.keepBytes(piece)
}
}
// decide settles the line held in pending and, if it is kept, writes it out
// with the separator that belongs in front of it.
//
// The separator goes in *front* because that is what StripNudge does: it splits
// on newlines, drops lines, and joins the rest — so the newline that vanishes
// with a dropped last line is the one before it, not the one after.
func (c *cappedOutput) decide() {
if c.drop(c.pending) {
c.state = lineDropped
c.pending = c.pending[:0]
return
}
c.state = lineKept
if c.preceded {
c.keepBytes([]byte{'\n'})
}
c.preceded = true
c.keepBytes(c.pending)
c.pending = c.pending[:0]
}
func (c *cappedOutput) endLine() {
if c.state == lineUndecided {
c.decide()
}
c.state, c.pending = lineUndecided, c.pending[:0]
}
// finish settles a last line that never got its newline — a command whose
// output does not end in one, which includes the case where that line is the
// nudge and the newline before it goes with it — and closes the spill file.
func (c *cappedOutput) finish() {
if c.finished {
return
}
c.finished = true
if c.drop != nil && c.state == lineUndecided {
c.decide()
}
if c.file != nil {
_ = c.file.Close()
c.file = nil
}
}
// keepBytes is the bounded part: the file first, then the ring, then the counts
// a notice is written from.
func (c *cappedOutput) keepBytes(b []byte) {
if len(b) == 0 {
return
}
c.tee(b)
c.total += len(b)
c.newlines += bytes.Count(b, []byte{'\n'})
c.endsNewline = b[len(b)-1] == '\n'
if len(b) >= c.carry {
copy(c.ring, b[len(b)-c.carry:])
c.next, c.round = 0, true
return
}
n := copy(c.ring[c.next:], b)
if n < len(b) {
copy(c.ring, b[n:])
c.next, c.round = len(b)-n, true
return
}
if c.next += n; c.next == c.carry {
c.next, c.round = 0, true
}
}
// tee sends everything to the spill file, opening it on the first byte that
// will not fit in the ring. Until that moment the ring holds the whole output,
// so nothing has been lost and no file is needed; at that moment the ring is
// flushed to the file and every later byte goes straight through.
func (c *cappedOutput) tee(b []byte) {
// partial means the spill file filled and was closed: the head it holds is
// the half the context does not have, and opening a fresh file now would
// capture a middle chunk under a name the notice calls "First N bytes" —
// while burning a spill name per later chunk and pointing the notice at
// the last, empty, one. Once stopped, the first file stays the answer.
if c.open == nil || c.teeFailed || c.finished || c.partial {
return
}
if c.file == nil {
// Either cap crossing is a reason to start the file, because either one
// means something will be cut. The line cap matters here on its own: a
// thin four-thousand-line listing is nowhere near the byte limit and is
// still going to lose most of itself, and a notice that could not name a
// file would be telling the model to run the command again.
if c.total+len(b) <= c.carry && c.newlines+bytes.Count(b, []byte{'\n'}) <= maxResultLines {
return
}
file, path, ok := c.open()
if !ok {
c.teeFailed = true
return
}
c.file, c.spillPath = file, path
c.writeSpill(c.ordered())
}
c.writeSpill(b)
}
func (c *cappedOutput) writeSpill(b []byte) {
if c.file == nil || len(b) == 0 {
return
}
room := c.carry*spillFileMultiple - c.written
if room <= 0 {
c.stopSpilling()
return
}
if room < len(b) {
b = b[:room]
defer c.stopSpilling()
}
n, err := c.file.Write(b)
c.written += n
if err != nil {
c.stopSpilling()
}
}
// stopSpilling closes the file and records that it holds only the beginning.
// The context still holds the end, so between them the two ends of a runaway
// output are both readable — which is more than either had before.
func (c *cappedOutput) stopSpilling() {
if c.file == nil {
return
}
_ = c.file.Close()
c.file = nil
c.partial = true
}
func (c *cappedOutput) ordered() []byte {
if !c.round {
return c.ring[:c.next]
}
held := make([]byte, 0, c.carry)
held = append(held, c.ring[c.next:]...)
return append(held, c.ring[:c.next]...)
}
// lines is the whole output's line count, the same count countLines makes of a
// string that was held whole.
func (c *cappedOutput) lines() int {
if c.total == 0 {
return 0
}
if c.endsNewline {
return c.newlines
}
return c.newlines + 1
}
// truncated reports whether anything was cut, which is what tells the caller
// the result already carries its own notice and must not be bounded twice.
func (c *cappedOutput) truncated() bool {
c.finish()
return c.total > c.carry || c.lines() > maxResultLines
}
// String renders what boundResult would have rendered from the whole output.
func (c *cappedOutput) String() string {
c.finish()
held := string(c.ordered())
if !c.truncated() {
// Nothing was lost: the ring holds every byte that was written.
return held
}
// Taken from the ring rather than from the whole, which is the same answer:
// keep is smaller than carry, so the kept region and the line boundary in
// front of it are both inside the ring. Only the two totals have to come
// from the counters, because the ring cannot know them.
cut := truncateTail(held, c.keep, maxResultLines)
cut.totalBytes, cut.totalLines = c.total, c.lines()
cut.byteWindow, cut.lineWindow = c.keep, maxResultLines
cut.tail = true
cut.spill = spillRef{path: c.spillPath, bytes: c.written, partial: c.partial}
return cut.render()
}