@@ -261,6 +261,17 @@ func (ex *RunExecutor) Run(ctx context.Context) (err error) {
261261 default :
262262 }
263263
264+ if errors .Is (err , ErrLogQuotaExceeded ) {
265+ log .Error (ctx , "Log quota exceeded" , "quota" , ex .jobLogs .quota )
266+ ex .SetJobStateWithTerminationReason (
267+ ctx ,
268+ schemas .JobStateFailed ,
269+ types .TerminationReasonLogQuotaExceeded ,
270+ fmt .Sprintf ("Job log output exceeded the hourly quota of %d bytes" , ex .jobLogs .quota ),
271+ )
272+ return fmt .Errorf ("log quota exceeded: %w" , err )
273+ }
274+
264275 // todo fail reason?
265276 log .Error (ctx , "Exec failed" , "err" , err )
266277 var exitError * exec.ExitError
@@ -283,6 +294,7 @@ func (ex *RunExecutor) SetJob(body schemas.SubmitBody) {
283294 ex .clusterInfo = body .ClusterInfo
284295 ex .secrets = body .Secrets
285296 ex .repoCredentials = body .RepoCredentials
297+ ex .jobLogs .SetQuota (body .LogQuotaHour )
286298 ex .state = WaitCode
287299}
288300
@@ -586,18 +598,51 @@ func (ex *RunExecutor) execJob(ctx context.Context, jobLogFile io.Writer) error
586598 defer func () { _ = cmd .Wait () }() // release resources if copy fails
587599
588600 stripper := ansistrip .NewWriter (ex .jobLogs , AnsiStripFlushInterval , AnsiStripMaxDelay , MaxBufferSize )
589- defer func () { _ = stripper .Close () }()
590601 logger := io .MultiWriter (jobLogFile , ex .jobWsLogs , stripper )
591- _ , err = io . Copy ( logger , ptm )
592- if err != nil && ! isPtyError ( err ) {
593- return fmt . Errorf ( "copy command output: %w" , err )
602+
603+ if err := ex . copyOutputWithQuota ( cmd , ptm , stripper , logger ); err != nil {
604+ return err
594605 }
595606 if err = cmd .Wait (); err != nil {
596607 return fmt .Errorf ("wait for command: %w" , err )
597608 }
598609 return nil
599610}
600611
612+ // copyOutputWithQuota streams process output through the log pipeline and
613+ // monitors for log quota exceeded. The quota signal is out-of-band (via channel)
614+ // because the ansistrip writer is async and swallows downstream write errors.
615+ func (ex * RunExecutor ) copyOutputWithQuota (cmd * exec.Cmd , ptm io.Reader , stripper io.Closer , logger io.Writer ) error {
616+ copyDone := make (chan error , 1 )
617+ go func () {
618+ _ , err := io .Copy (logger , ptm )
619+ copyDone <- err
620+ }()
621+
622+ // Wait for either io.Copy to finish or quota to be exceeded.
623+ var copyErr error
624+ select {
625+ case copyErr = <- copyDone :
626+ case <- ex .jobLogs .QuotaExceeded ():
627+ _ = cmd .Process .Kill ()
628+ <- copyDone
629+ }
630+
631+ // Flush the ansistrip buffer — may also trigger quota exceeded.
632+ _ = stripper .Close ()
633+
634+ select {
635+ case <- ex .jobLogs .QuotaExceeded ():
636+ return ErrLogQuotaExceeded
637+ default :
638+ }
639+
640+ if copyErr != nil && ! isPtyError (copyErr ) {
641+ return fmt .Errorf ("copy command output: %w" , copyErr )
642+ }
643+ return nil
644+ }
645+
601646// setupGitCredentials must be called from Run after setJobUser
602647func (ex * RunExecutor ) setupGitCredentials (ctx context.Context ) (func (), error ) {
603648 if ex .repoCredentials == nil {
0 commit comments