fix(guest): escalate cancellation SIGINT/SIGTERM/SIGKILL, stream through frame writers, forward init signals; cancel channel in Drive
- commit
- 8784b11a7a400942fc6c0c2dda70628c27f9cf57
- author
- brice · · 2026-10-12 16:00 UTC
- parent
- af04fdc
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
16 files · +682 -145
A buf.gen.yaml +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | version: v2 | |
| 2 | + | plugins: | |
| 3 | + | - local: [go, tool, protoc-gen-go] | |
| 4 | + | out: ../.. | |
| 5 | + | opt: module=bnema.dev/ermite-runner | |
| @@ -0,0 +1,5 @@ | |||||
| 1 | + | version: v2 | |||
| 2 | + | plugins: | |||
| 3 | + | - local: [go, tool, protoc-gen-go] | |||
| 4 | + | out: ../.. | |||
| 5 | + | opt: module=bnema.dev/ermite-runner | |||
A buf.yaml +12 -0
| @@ -0,0 +1,12 @@ | |||
| 1 | + | version: v2 | |
| 2 | + | modules: | |
| 3 | + | - path: internal/execstream | |
| 4 | + | lint: | |
| 5 | + | use: | |
| 6 | + | - STANDARD | |
| 7 | + | except: | |
| 8 | + | # The proto is internal to this module and lives next to its Go package. | |
| 9 | + | - PACKAGE_DIRECTORY_MATCH | |
| 10 | + | breaking: | |
| 11 | + | use: | |
| 12 | + | - FILE | |
| @@ -0,0 +1,12 @@ | |||||
| 1 | + | version: v2 | |||
| 2 | + | modules: | |||
| 3 | + | - path: internal/execstream | |||
| 4 | + | lint: | |||
| 5 | + | use: | |||
| 6 | + | - STANDARD | |||
| 7 | + | except: | |||
| 8 | + | # The proto is internal to this module and lives next to its Go package. | |||
| 9 | + | - PACKAGE_DIRECTORY_MATCH | |||
| 10 | + | breaking: | |||
| 11 | + | use: | |||
| 12 | + | - FILE | |||
M go.mod +76 -6
| @@ -12,25 +12,95 @@ require ( | |||
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | require ( | |
| 15 | + | buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20260722160903-4d94f3df3a7b.2 // indirect | |
| 16 | + | buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.12-20250109164928-1da0de137947.2 // indirect | |
| 17 | + | buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 // indirect | |
| 18 | + | buf.build/gen/go/bufbuild/registry/connectrpc/go v1.20.0-20260831211851-b4432e12a6e7.1 // indirect | |
| 19 | + | buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.12-20260831211851-b4432e12a6e7.2 // indirect | |
| 20 | + | buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.12-20241007202033-cf42259fcbfc.2 // indirect | |
| 21 | + | buf.build/go/app v0.2.1-0.20260824172350-b0e76892c61a // indirect | |
| 22 | + | buf.build/go/bufplugin v0.10.0 // indirect | |
| 23 | + | buf.build/go/bufprivateusage v0.1.0 // indirect | |
| 24 | + | buf.build/go/interrupt v1.1.0 // indirect | |
| 25 | + | buf.build/go/protovalidate v1.4.0 // indirect | |
| 26 | + | buf.build/go/protoyaml v0.7.0 // indirect | |
| 27 | + | buf.build/go/spdx v0.2.0 // indirect | |
| 28 | + | buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 // indirect | |
| 29 | + | cel.dev/cel-go v0.32.0 // indirect | |
| 30 | + | cel.dev/expr v0.25.3 // indirect | |
| 31 | + | connectrpc.com/connect v1.20.0 // indirect | |
| 32 | + | connectrpc.com/otelconnect v0.9.0 // indirect | |
| 15 | 33 | github.com/Microsoft/go-winio v0.6.2 // indirect | |
| 34 | + | github.com/antlr4-go/antlr/v4 v4.13.1 // indirect | |
| 35 | + | github.com/bufbuild/buf v1.73.0 // indirect | |
| 36 | + | github.com/bufbuild/protocompile v0.14.2-0.20260910151042-7436f7c76201 // indirect | |
| 37 | + | github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b // indirect | |
| 16 | 38 | github.com/cespare/xxhash/v2 v2.3.0 // indirect | |
| 39 | + | github.com/cli/browser v1.3.0 // indirect | |
| 17 | 40 | github.com/containerd/errdefs/pkg v0.3.0 // indirect | |
| 41 | + | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect | |
| 18 | 42 | github.com/distribution/reference v0.6.0 // indirect | |
| 43 | + | github.com/docker/cli v29.8.0+incompatible // indirect | |
| 44 | + | github.com/docker/docker-credential-helpers v0.9.9 // indirect | |
| 19 | 45 | github.com/docker/go-connections v0.8.2 // indirect | |
| 20 | 46 | github.com/docker/go-units v0.5.0 // indirect | |
| 21 | - | github.com/felixge/httpsnoop v1.0.4 // indirect | |
| 47 | + | github.com/felixge/httpsnoop v1.1.0 // indirect | |
| 22 | 48 | github.com/go-logr/logr v1.4.4 // indirect | |
| 23 | 49 | github.com/go-logr/stdr v1.2.2 // indirect | |
| 50 | + | github.com/gofrs/flock v0.13.1 // indirect | |
| 51 | + | github.com/google/go-containerregistry v0.22.1 // indirect | |
| 52 | + | github.com/google/uuid v1.6.0 // indirect | |
| 53 | + | github.com/inconshreveable/mousetrap v1.1.0 // indirect | |
| 54 | + | github.com/jdx/go-netrc v1.0.0 // indirect | |
| 55 | + | github.com/klauspost/compress v1.20.0 // indirect | |
| 56 | + | github.com/klauspost/pgzip v1.2.6 // indirect | |
| 24 | 57 | github.com/mattn/go-colorable v0.1.15 // indirect | |
| 25 | - | github.com/mattn/go-isatty v0.0.22 // indirect | |
| 58 | + | github.com/mattn/go-isatty v0.0.24 // indirect | |
| 26 | 59 | github.com/moby/docker-image-spec v1.3.1 // indirect | |
| 27 | 60 | github.com/opencontainers/go-digest v1.0.0 // indirect | |
| 28 | 61 | github.com/opencontainers/image-spec v1.1.1 // indirect | |
| 62 | + | github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 // indirect | |
| 63 | + | github.com/quic-go/qpack v0.6.0 // indirect | |
| 64 | + | github.com/quic-go/quic-go v0.62.0 // indirect | |
| 65 | + | github.com/rivo/uniseg v0.4.7 // indirect | |
| 66 | + | github.com/rs/cors v1.11.1 // indirect | |
| 29 | 67 | github.com/rs/zerolog v1.35.1 // indirect | |
| 68 | + | github.com/russross/blackfriday/v2 v2.1.0 // indirect | |
| 69 | + | github.com/segmentio/asm v1.2.1 // indirect | |
| 70 | + | github.com/segmentio/encoding v0.5.4 // indirect | |
| 71 | + | github.com/sirupsen/logrus v1.10.2 // indirect | |
| 72 | + | github.com/spf13/cobra v1.10.2 // indirect | |
| 73 | + | github.com/spf13/pflag v1.0.10 // indirect | |
| 74 | + | github.com/tetratelabs/wazero v1.12.0 // indirect | |
| 75 | + | github.com/tidwall/btree v1.8.1 // indirect | |
| 76 | + | go.lsp.dev/jsonrpc2 v0.10.0 // indirect | |
| 77 | + | go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2 // indirect | |
| 78 | + | go.lsp.dev/protocol v0.12.0 // indirect | |
| 79 | + | go.lsp.dev/uri v0.3.0 // indirect | |
| 30 | 80 | go.opentelemetry.io/auto/sdk v1.2.1 // indirect | |
| 31 | - | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect | |
| 32 | - | go.opentelemetry.io/otel v1.45.0 // indirect | |
| 33 | - | go.opentelemetry.io/otel/metric v1.45.0 // indirect | |
| 34 | - | go.opentelemetry.io/otel/trace v1.45.0 // indirect | |
| 81 | + | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 // indirect | |
| 82 | + | go.opentelemetry.io/otel v1.46.0 // indirect | |
| 83 | + | go.opentelemetry.io/otel/metric v1.46.0 // indirect | |
| 84 | + | go.opentelemetry.io/otel/trace v1.46.0 // indirect | |
| 85 | + | go.uber.org/multierr v1.11.0 // indirect | |
| 86 | + | go.uber.org/zap v1.28.0 // indirect | |
| 87 | + | go.yaml.in/yaml/v3 v3.0.5 // indirect | |
| 88 | + | golang.org/x/crypto v0.56.0 // indirect | |
| 89 | + | golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect | |
| 90 | + | golang.org/x/mod v0.40.0 // indirect | |
| 91 | + | golang.org/x/net v0.58.0 // indirect | |
| 92 | + | golang.org/x/sync v0.23.0 // indirect | |
| 93 | + | golang.org/x/term v0.45.0 // indirect | |
| 94 | + | golang.org/x/text v0.41.0 // indirect | |
| 95 | + | google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect | |
| 96 | + | google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 // indirect | |
| 35 | 97 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect | |
| 98 | + | gopkg.in/yaml.v3 v3.0.1 // indirect | |
| 99 | + | mvdan.cc/xurls/v2 v2.6.0 // indirect | |
| 100 | + | pluginrpc.com/pluginrpc v0.5.0 // indirect | |
| 101 | + | ) | |
| 102 | + | ||
| 103 | + | tool ( | |
| 104 | + | github.com/bufbuild/buf/cmd/buf | |
| 105 | + | google.golang.org/protobuf/cmd/protoc-gen-go | |
| 36 | 106 | ) | |
| @@ -12,25 +12,95 @@ require ( | |||||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | require ( | 14 | require ( | ||
| 15 | + | buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20260722160903-4d94f3df3a7b.2 // indirect | |||
| 16 | + | buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.12-20250109164928-1da0de137947.2 // indirect | |||
| 17 | + | buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 // indirect | |||
| 18 | + | buf.build/gen/go/bufbuild/registry/connectrpc/go v1.20.0-20260831211851-b4432e12a6e7.1 // indirect | |||
| 19 | + | buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.12-20260831211851-b4432e12a6e7.2 // indirect | |||
| 20 | + | buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.12-20241007202033-cf42259fcbfc.2 // indirect | |||
| 21 | + | buf.build/go/app v0.2.1-0.20260824172350-b0e76892c61a // indirect | |||
| 22 | + | buf.build/go/bufplugin v0.10.0 // indirect | |||
| 23 | + | buf.build/go/bufprivateusage v0.1.0 // indirect | |||
| 24 | + | buf.build/go/interrupt v1.1.0 // indirect | |||
| 25 | + | buf.build/go/protovalidate v1.4.0 // indirect | |||
| 26 | + | buf.build/go/protoyaml v0.7.0 // indirect | |||
| 27 | + | buf.build/go/spdx v0.2.0 // indirect | |||
| 28 | + | buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 // indirect | |||
| 29 | + | cel.dev/cel-go v0.32.0 // indirect | |||
| 30 | + | cel.dev/expr v0.25.3 // indirect | |||
| 31 | + | connectrpc.com/connect v1.20.0 // indirect | |||
| 32 | + | connectrpc.com/otelconnect v0.9.0 // indirect | |||
| 15 | github.com/Microsoft/go-winio v0.6.2 // indirect | 33 | github.com/Microsoft/go-winio v0.6.2 // indirect | ||
| 34 | + | github.com/antlr4-go/antlr/v4 v4.13.1 // indirect | |||
| 35 | + | github.com/bufbuild/buf v1.73.0 // indirect | |||
| 36 | + | github.com/bufbuild/protocompile v0.14.2-0.20260910151042-7436f7c76201 // indirect | |||
| 37 | + | github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b // indirect | |||
| 16 | github.com/cespare/xxhash/v2 v2.3.0 // indirect | 38 | github.com/cespare/xxhash/v2 v2.3.0 // indirect | ||
| 39 | + | github.com/cli/browser v1.3.0 // indirect | |||
| 17 | github.com/containerd/errdefs/pkg v0.3.0 // indirect | 40 | github.com/containerd/errdefs/pkg v0.3.0 // indirect | ||
| 41 | + | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect | |||
| 18 | github.com/distribution/reference v0.6.0 // indirect | 42 | github.com/distribution/reference v0.6.0 // indirect | ||
| 43 | + | github.com/docker/cli v29.8.0+incompatible // indirect | |||
| 44 | + | github.com/docker/docker-credential-helpers v0.9.9 // indirect | |||
| 19 | github.com/docker/go-connections v0.8.2 // indirect | 45 | github.com/docker/go-connections v0.8.2 // indirect | ||
| 20 | github.com/docker/go-units v0.5.0 // indirect | 46 | github.com/docker/go-units v0.5.0 // indirect | ||
| 21 | - | github.com/felixge/httpsnoop v1.0.4 // indirect | 47 | + | github.com/felixge/httpsnoop v1.1.0 // indirect |
| 22 | github.com/go-logr/logr v1.4.4 // indirect | 48 | github.com/go-logr/logr v1.4.4 // indirect | ||
| 23 | github.com/go-logr/stdr v1.2.2 // indirect | 49 | github.com/go-logr/stdr v1.2.2 // indirect | ||
| 50 | + | github.com/gofrs/flock v0.13.1 // indirect | |||
| 51 | + | github.com/google/go-containerregistry v0.22.1 // indirect | |||
| 52 | + | github.com/google/uuid v1.6.0 // indirect | |||
| 53 | + | github.com/inconshreveable/mousetrap v1.1.0 // indirect | |||
| 54 | + | github.com/jdx/go-netrc v1.0.0 // indirect | |||
| 55 | + | github.com/klauspost/compress v1.20.0 // indirect | |||
| 56 | + | github.com/klauspost/pgzip v1.2.6 // indirect | |||
| 24 | github.com/mattn/go-colorable v0.1.15 // indirect | 57 | github.com/mattn/go-colorable v0.1.15 // indirect | ||
| 25 | - | github.com/mattn/go-isatty v0.0.22 // indirect | 58 | + | github.com/mattn/go-isatty v0.0.24 // indirect |
| 26 | github.com/moby/docker-image-spec v1.3.1 // indirect | 59 | github.com/moby/docker-image-spec v1.3.1 // indirect | ||
| 27 | github.com/opencontainers/go-digest v1.0.0 // indirect | 60 | github.com/opencontainers/go-digest v1.0.0 // indirect | ||
| 28 | github.com/opencontainers/image-spec v1.1.1 // indirect | 61 | github.com/opencontainers/image-spec v1.1.1 // indirect | ||
| 62 | + | github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 // indirect | |||
| 63 | + | github.com/quic-go/qpack v0.6.0 // indirect | |||
| 64 | + | github.com/quic-go/quic-go v0.62.0 // indirect | |||
| 65 | + | github.com/rivo/uniseg v0.4.7 // indirect | |||
| 66 | + | github.com/rs/cors v1.11.1 // indirect | |||
| 29 | github.com/rs/zerolog v1.35.1 // indirect | 67 | github.com/rs/zerolog v1.35.1 // indirect | ||
| 68 | + | github.com/russross/blackfriday/v2 v2.1.0 // indirect | |||
| 69 | + | github.com/segmentio/asm v1.2.1 // indirect | |||
| 70 | + | github.com/segmentio/encoding v0.5.4 // indirect | |||
| 71 | + | github.com/sirupsen/logrus v1.10.2 // indirect | |||
| 72 | + | github.com/spf13/cobra v1.10.2 // indirect | |||
| 73 | + | github.com/spf13/pflag v1.0.10 // indirect | |||
| 74 | + | github.com/tetratelabs/wazero v1.12.0 // indirect | |||
| 75 | + | github.com/tidwall/btree v1.8.1 // indirect | |||
| 76 | + | go.lsp.dev/jsonrpc2 v0.10.0 // indirect | |||
| 77 | + | go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2 // indirect | |||
| 78 | + | go.lsp.dev/protocol v0.12.0 // indirect | |||
| 79 | + | go.lsp.dev/uri v0.3.0 // indirect | |||
| 30 | go.opentelemetry.io/auto/sdk v1.2.1 // indirect | 80 | go.opentelemetry.io/auto/sdk v1.2.1 // indirect | ||
| 31 | - | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect | 81 | + | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 // indirect |
| 32 | - | go.opentelemetry.io/otel v1.45.0 // indirect | 82 | + | go.opentelemetry.io/otel v1.46.0 // indirect |
| 33 | - | go.opentelemetry.io/otel/metric v1.45.0 // indirect | 83 | + | go.opentelemetry.io/otel/metric v1.46.0 // indirect |
| 34 | - | go.opentelemetry.io/otel/trace v1.45.0 // indirect | 84 | + | go.opentelemetry.io/otel/trace v1.46.0 // indirect |
| 85 | + | go.uber.org/multierr v1.11.0 // indirect | |||
| 86 | + | go.uber.org/zap v1.28.0 // indirect | |||
| 87 | + | go.yaml.in/yaml/v3 v3.0.5 // indirect | |||
| 88 | + | golang.org/x/crypto v0.56.0 // indirect | |||
| 89 | + | golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect | |||
| 90 | + | golang.org/x/mod v0.40.0 // indirect | |||
| 91 | + | golang.org/x/net v0.58.0 // indirect | |||
| 92 | + | golang.org/x/sync v0.23.0 // indirect | |||
| 93 | + | golang.org/x/term v0.45.0 // indirect | |||
| 94 | + | golang.org/x/text v0.41.0 // indirect | |||
| 95 | + | google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect | |||
| 96 | + | google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 // indirect | |||
| 35 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect | 97 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect | ||
| 98 | + | gopkg.in/yaml.v3 v3.0.1 // indirect | |||
| 99 | + | mvdan.cc/xurls/v2 v2.6.0 // indirect | |||
| 100 | + | pluginrpc.com/pluginrpc v0.5.0 // indirect | |||
| 101 | + | ) | |||
| 102 | + | ||||
| 103 | + | tool ( | |||
| 104 | + | github.com/bufbuild/buf/cmd/buf | |||
| 105 | + | google.golang.org/protobuf/cmd/protoc-gen-go | |||
| 36 | ) | 106 | ) | ||
M go.sum +178 -20 generated file: collapsed
M internal/execstream/exec.proto +8 -2
| @@ -54,5 +54,11 @@ message Exit { | |||
| 54 | 54 | string error = 2; // set when the executor failed to run the script at all | |
| 55 | 55 | } | |
| 56 | 56 | ||
| 57 | - | // Cancel asks the executor to stop the running script: SIGINT now. | |
| 58 | - | message Cancel {} | |
| 57 | + | // Cancel asks the executor to stop the running script. The executor escalates | |
| 58 | + | // like the official runner: SIGINT to the script's process group at once, | |
| 59 | + | // SIGTERM 7.5 s later, SIGKILL 2.5 s after that. | |
| 60 | + | message Cancel { | |
| 61 | + | // reason is for the job log and diagnostics ("cancelled by the user", | |
| 62 | + | // "job timeout", ...); it never changes the behaviour. | |
| 63 | + | string reason = 1; | |
| 64 | + | } | |
| @@ -54,5 +54,11 @@ message Exit { | |||||
| 54 | string error = 2; // set when the executor failed to run the script at all | 54 | string error = 2; // set when the executor failed to run the script at all | ||
| 55 | } | 55 | } | ||
| 56 | 56 | ||||
| 57 | - | // Cancel asks the executor to stop the running script: SIGINT now. | 57 | + | // Cancel asks the executor to stop the running script. The executor escalates |
| 58 | - | message Cancel {} | 58 | + | // like the official runner: SIGINT to the script's process group at once, |
| 59 | + | // SIGTERM 7.5 s later, SIGKILL 2.5 s after that. | |||
| 60 | + | message Cancel { | |||
| 61 | + | // reason is for the job log and diagnostics ("cancelled by the user", | |||
| 62 | + | // "job timeout", ...); it never changes the behaviour. | |||
| 63 | + | string reason = 1; | |||
| 64 | + | } | |||
M internal/execstream/execpb/exec.pb.go +19 -6 generated file: collapsed
M internal/execstream/frame.go +24 -13
| @@ -9,11 +9,10 @@ | |||
| 9 | 9 | // transport-agnostic: vsock in a micro-VM, a Unix socket in a container. | |
| 10 | 10 | package execstream | |
| 11 | 11 | ||
| 12 | - | //go:generate protoc --proto_path=. --go_out=execpb --go_opt=paths=source_relative exec.proto | |
| 12 | + | //go:generate go tool buf generate ../.. --template ../../buf.gen.yaml | |
| 13 | 13 | ||
| 14 | 14 | import ( | |
| 15 | 15 | "bufio" | |
| 16 | - | "context" | |
| 17 | 16 | "encoding/binary" | |
| 18 | 17 | "errors" | |
| 19 | 18 | "fmt" | |
| @@ -92,10 +91,15 @@ func (c *Conn) Recv() (*execpb.Frame, error) { | |||
| 92 | 91 | ||
| 93 | 92 | // Drive is the host side of a session: it waits for the executor's Hello, sends | |
| 94 | 93 | // run, passes every Output frame to onOutput, and returns the Exit frame. | |
| 95 | - | // When ctx ends first, a Cancel frame is sent once and Drive keeps waiting for | |
| 96 | - | // the executor's Exit; the caller bounds that wait by stopping the sandbox, | |
| 97 | - | // which closes the stream and makes Drive return an error. | |
| 98 | - | func Drive(ctx context.Context, rw io.ReadWriter, run *execpb.Run, onOutput func(*execpb.Output)) (*execpb.Hello, *execpb.Exit, error) { | |
| 94 | + | // | |
| 95 | + | // cancel, when non-nil, asks the executor to stop the script: the first value | |
| 96 | + | // received is sent as one Cancel frame (the executor escalates SIGINT, SIGTERM, | |
| 97 | + | // SIGKILL by itself); closing the channel without a value sends nothing. It is | |
| 98 | + | // deliberately not a context: cancelling the session's context would also abort | |
| 99 | + | // the reads that deliver the executor's Exit. Drive keeps waiting for Exit after | |
| 100 | + | // a Cancel; the caller bounds that wait by stopping the sandbox, which closes | |
| 101 | + | // the stream and makes Drive return an error. | |
| 102 | + | func Drive(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, onOutput func(*execpb.Output)) (*execpb.Hello, *execpb.Exit, error) { | |
| 99 | 103 | c := NewConn(rw) | |
| 100 | 104 | first, err := c.Recv() | |
| 101 | 105 | if err != nil { | |
| @@ -111,13 +115,20 @@ func Drive(ctx context.Context, rw io.ReadWriter, run *execpb.Run, onOutput func | |||
| 111 | 115 | ||
| 112 | 116 | done := make(chan struct{}) | |
| 113 | 117 | defer close(done) | |
| 114 | - | go func() { | |
| 115 | - | select { | |
| 116 | - | case <-ctx.Done(): | |
| 117 | - | _ = c.Send(&execpb.Frame{Body: &execpb.Frame_Cancel{Cancel: &execpb.Cancel{}}}) | |
| 118 | - | case <-done: | |
| 119 | - | } | |
| 120 | - | }() | |
| 118 | + | if cancel != nil { | |
| 119 | + | go func() { | |
| 120 | + | select { | |
| 121 | + | case cn, ok := <-cancel: | |
| 122 | + | if ok { | |
| 123 | + | if cn == nil { | |
| 124 | + | cn = &execpb.Cancel{} | |
| 125 | + | } | |
| 126 | + | _ = c.Send(&execpb.Frame{Body: &execpb.Frame_Cancel{Cancel: cn}}) | |
| 127 | + | } | |
| 128 | + | case <-done: | |
| 129 | + | } | |
| 130 | + | }() | |
| 131 | + | } | |
| 121 | 132 | ||
| 122 | 133 | for { | |
| 123 | 134 | f, err := c.Recv() | |
| @@ -9,11 +9,10 @@ | |||||
| 9 | // transport-agnostic: vsock in a micro-VM, a Unix socket in a container. | 9 | // transport-agnostic: vsock in a micro-VM, a Unix socket in a container. | ||
| 10 | package execstream | 10 | package execstream | ||
| 11 | 11 | ||||
| 12 | - | //go:generate protoc --proto_path=. --go_out=execpb --go_opt=paths=source_relative exec.proto | 12 | + | //go:generate go tool buf generate ../.. --template ../../buf.gen.yaml |
| 13 | 13 | ||||
| 14 | import ( | 14 | import ( | ||
| 15 | "bufio" | 15 | "bufio" | ||
| 16 | - | "context" | |||
| 17 | "encoding/binary" | 16 | "encoding/binary" | ||
| 18 | "errors" | 17 | "errors" | ||
| 19 | "fmt" | 18 | "fmt" | ||
| @@ -92,10 +91,15 @@ func (c *Conn) Recv() (*execpb.Frame, error) { | |||||
| 92 | 91 | ||||
| 93 | // Drive is the host side of a session: it waits for the executor's Hello, sends | 92 | // Drive is the host side of a session: it waits for the executor's Hello, sends | ||
| 94 | // run, passes every Output frame to onOutput, and returns the Exit frame. | 93 | // run, passes every Output frame to onOutput, and returns the Exit frame. | ||
| 95 | - | // When ctx ends first, a Cancel frame is sent once and Drive keeps waiting for | 94 | + | // |
| 96 | - | // the executor's Exit; the caller bounds that wait by stopping the sandbox, | 95 | + | // cancel, when non-nil, asks the executor to stop the script: the first value |
| 97 | - | // which closes the stream and makes Drive return an error. | 96 | + | // received is sent as one Cancel frame (the executor escalates SIGINT, SIGTERM, |
| 98 | - | func Drive(ctx context.Context, rw io.ReadWriter, run *execpb.Run, onOutput func(*execpb.Output)) (*execpb.Hello, *execpb.Exit, error) { | 97 | + | // SIGKILL by itself); closing the channel without a value sends nothing. It is |
| 98 | + | // deliberately not a context: cancelling the session's context would also abort | |||
| 99 | + | // the reads that deliver the executor's Exit. Drive keeps waiting for Exit after | |||
| 100 | + | // a Cancel; the caller bounds that wait by stopping the sandbox, which closes | |||
| 101 | + | // the stream and makes Drive return an error. | |||
| 102 | + | func Drive(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, onOutput func(*execpb.Output)) (*execpb.Hello, *execpb.Exit, error) { | |||
| 99 | c := NewConn(rw) | 103 | c := NewConn(rw) | ||
| 100 | first, err := c.Recv() | 104 | first, err := c.Recv() | ||
| 101 | if err != nil { | 105 | if err != nil { | ||
| @@ -111,13 +115,20 @@ func Drive(ctx context.Context, rw io.ReadWriter, run *execpb.Run, onOutput func | |||||
| 111 | 115 | ||||
| 112 | done := make(chan struct{}) | 116 | done := make(chan struct{}) | ||
| 113 | defer close(done) | 117 | defer close(done) | ||
| 114 | - | go func() { | 118 | + | if cancel != nil { |
| 115 | - | select { | 119 | + | go func() { |
| 116 | - | case <-ctx.Done(): | 120 | + | select { |
| 117 | - | _ = c.Send(&execpb.Frame{Body: &execpb.Frame_Cancel{Cancel: &execpb.Cancel{}}}) | 121 | + | case cn, ok := <-cancel: |
| 118 | - | case <-done: | 122 | + | if ok { |
| 119 | - | } | 123 | + | if cn == nil { |
| 120 | - | }() | 124 | + | cn = &execpb.Cancel{} |
| 125 | + | } | |||
| 126 | + | _ = c.Send(&execpb.Frame{Body: &execpb.Frame_Cancel{Cancel: cn}}) | |||
| 127 | + | } | |||
| 128 | + | case <-done: | |||
| 129 | + | } | |||
| 130 | + | }() | |||
| 131 | + | } | |||
| 121 | 132 | ||||
| 122 | for { | 133 | for { | ||
| 123 | f, err := c.Recv() | 134 | f, err := c.Recv() | ||
M internal/execstream/frame_test.go +6 -7
| @@ -4,7 +4,6 @@ package execstream | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
| 7 | - | "context" | |
| 8 | 7 | "encoding/binary" | |
| 9 | 8 | "errors" | |
| 10 | 9 | "io" | |
| @@ -93,7 +92,7 @@ func TestDrive(t *testing.T) { | |||
| 93 | 92 | _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Exit{Exit: &execpb.Exit{Code: 7}}}) | |
| 94 | 93 | }() | |
| 95 | 94 | var got []byte | |
| 96 | - | hello, exit, err := Drive(context.Background(), host, &execpb.Run{Script: "echo hi"}, func(o *execpb.Output) { got = append(got, o.Data...) }) | |
| 95 | + | hello, exit, err := Drive(host, &execpb.Run{Script: "echo hi"}, nil, func(o *execpb.Output) { got = append(got, o.Data...) }) | |
| 97 | 96 | if err != nil { | |
| 98 | 97 | t.Fatal(err) | |
| 99 | 98 | } | |
| @@ -112,12 +111,12 @@ func TestDriveCancelSendsCancel(t *testing.T) { | |||
| 112 | 111 | _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Hello{Hello: &execpb.Hello{}}}) | |
| 113 | 112 | _, _ = g.Recv() // run | |
| 114 | 113 | f, err := g.Recv() | |
| 115 | - | gotCancel <- err == nil && f.GetCancel() != nil | |
| 114 | + | gotCancel <- err == nil && f.GetCancel() != nil && f.GetCancel().Reason == "cancelled by the user" | |
| 116 | 115 | _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Exit{Exit: &execpb.Exit{Code: 130}}}) | |
| 117 | 116 | }() | |
| 118 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 119 | - | time.AfterFunc(50*time.Millisecond, cancel) | |
| 120 | - | _, exit, err := Drive(ctx, host, &execpb.Run{}, nil) | |
| 117 | + | cancel := make(chan *execpb.Cancel, 1) | |
| 118 | + | time.AfterFunc(50*time.Millisecond, func() { cancel <- &execpb.Cancel{Reason: "cancelled by the user"} }) | |
| 119 | + | _, exit, err := Drive(host, &execpb.Run{}, cancel, nil) | |
| 121 | 120 | if err != nil || exit.Code != 130 { | |
| 122 | 121 | t.Fatalf("exit=%v err=%v", exit, err) | |
| 123 | 122 | } | |
| @@ -133,7 +132,7 @@ func TestDriveHelloMissing(t *testing.T) { | |||
| 133 | 132 | defer func() { _ = guest.Close() }() | |
| 134 | 133 | _ = NewConn(guest).Send(&execpb.Frame{Body: &execpb.Frame_Exit{Exit: &execpb.Exit{}}}) | |
| 135 | 134 | }() | |
| 136 | - | if _, _, err := Drive(context.Background(), host, &execpb.Run{}, nil); err == nil { | |
| 135 | + | if _, _, err := Drive(host, &execpb.Run{}, nil, nil); err == nil { | |
| 137 | 136 | t.Fatal("want error when the first frame is not hello") | |
| 138 | 137 | } | |
| 139 | 138 | } | |
| @@ -4,7 +4,6 @@ package execstream | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "bytes" | 6 | "bytes" | ||
| 7 | - | "context" | |||
| 8 | "encoding/binary" | 7 | "encoding/binary" | ||
| 9 | "errors" | 8 | "errors" | ||
| 10 | "io" | 9 | "io" | ||
| @@ -93,7 +92,7 @@ func TestDrive(t *testing.T) { | |||||
| 93 | _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Exit{Exit: &execpb.Exit{Code: 7}}}) | 92 | _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Exit{Exit: &execpb.Exit{Code: 7}}}) | ||
| 94 | }() | 93 | }() | ||
| 95 | var got []byte | 94 | var got []byte | ||
| 96 | - | hello, exit, err := Drive(context.Background(), host, &execpb.Run{Script: "echo hi"}, func(o *execpb.Output) { got = append(got, o.Data...) }) | 95 | + | hello, exit, err := Drive(host, &execpb.Run{Script: "echo hi"}, nil, func(o *execpb.Output) { got = append(got, o.Data...) }) |
| 97 | if err != nil { | 96 | if err != nil { | ||
| 98 | t.Fatal(err) | 97 | t.Fatal(err) | ||
| 99 | } | 98 | } | ||
| @@ -112,12 +111,12 @@ func TestDriveCancelSendsCancel(t *testing.T) { | |||||
| 112 | _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Hello{Hello: &execpb.Hello{}}}) | 111 | _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Hello{Hello: &execpb.Hello{}}}) | ||
| 113 | _, _ = g.Recv() // run | 112 | _, _ = g.Recv() // run | ||
| 114 | f, err := g.Recv() | 113 | f, err := g.Recv() | ||
| 115 | - | gotCancel <- err == nil && f.GetCancel() != nil | 114 | + | gotCancel <- err == nil && f.GetCancel() != nil && f.GetCancel().Reason == "cancelled by the user" |
| 116 | _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Exit{Exit: &execpb.Exit{Code: 130}}}) | 115 | _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Exit{Exit: &execpb.Exit{Code: 130}}}) | ||
| 117 | }() | 116 | }() | ||
| 118 | - | ctx, cancel := context.WithCancel(context.Background()) | 117 | + | cancel := make(chan *execpb.Cancel, 1) |
| 119 | - | time.AfterFunc(50*time.Millisecond, cancel) | 118 | + | time.AfterFunc(50*time.Millisecond, func() { cancel <- &execpb.Cancel{Reason: "cancelled by the user"} }) |
| 120 | - | _, exit, err := Drive(ctx, host, &execpb.Run{}, nil) | 119 | + | _, exit, err := Drive(host, &execpb.Run{}, cancel, nil) |
| 121 | if err != nil || exit.Code != 130 { | 120 | if err != nil || exit.Code != 130 { | ||
| 122 | t.Fatalf("exit=%v err=%v", exit, err) | 121 | t.Fatalf("exit=%v err=%v", exit, err) | ||
| 123 | } | 122 | } | ||
| @@ -133,7 +132,7 @@ func TestDriveHelloMissing(t *testing.T) { | |||||
| 133 | defer func() { _ = guest.Close() }() | 132 | defer func() { _ = guest.Close() }() | ||
| 134 | _ = NewConn(guest).Send(&execpb.Frame{Body: &execpb.Frame_Exit{Exit: &execpb.Exit{}}}) | 133 | _ = NewConn(guest).Send(&execpb.Frame{Body: &execpb.Frame_Exit{Exit: &execpb.Exit{}}}) | ||
| 135 | }() | 134 | }() | ||
| 136 | - | if _, _, err := Drive(context.Background(), host, &execpb.Run{}, nil); err == nil { | 135 | + | if _, _, err := Drive(host, &execpb.Run{}, nil, nil); err == nil { |
| 137 | t.Fatal("want error when the first frame is not hello") | 136 | t.Fatal("want error when the first frame is not hello") | ||
| 138 | } | 137 | } | ||
| 139 | } | 138 | } | ||
M internal/guest/exec.go +144 -43
| @@ -20,6 +20,17 @@ import ( | |||
| 20 | 20 | // DefaultScript is what the stub executor runs when the host sends no script. | |
| 21 | 21 | const DefaultScript = "uname -a; id; cat /etc/os-release; docker info" | |
| 22 | 22 | ||
| 23 | + | // Cancellation timings, like the official runner's ProcessInvoker: SIGINT, then | |
| 24 | + | // SIGTERM after 7.5 s, then SIGKILL 2.5 s later. | |
| 25 | + | const ( | |
| 26 | + | DefaultTermAfter = 7500 * time.Millisecond | |
| 27 | + | DefaultKillAfter = 2500 * time.Millisecond | |
| 28 | + | // DefaultWaitDelay bounds how long the executor waits for the output pipes | |
| 29 | + | // to close after the script's shell exited (background children may hold | |
| 30 | + | // them open). | |
| 31 | + | DefaultWaitDelay = 500 * time.Millisecond | |
| 32 | + | ) | |
| 33 | + | ||
| 23 | 34 | // ExecConfig configures the executor. | |
| 24 | 35 | type ExecConfig struct { | |
| 25 | 36 | Target Target | |
| @@ -31,22 +42,48 @@ type ExecConfig struct { | |||
| 31 | 42 | ConnectTimeout time.Duration | |
| 32 | 43 | // RunTimeout bounds the wait for the host's Run frame (default 60 s). | |
| 33 | 44 | RunTimeout time.Duration | |
| 45 | + | // TermAfter and KillAfter are the escalation delays after a Cancel (or after | |
| 46 | + | // the executor's own context ends): SIGINT at once, SIGTERM after TermAfter, | |
| 47 | + | // SIGKILL KillAfter later. WaitDelay is the pipe-drain grace after the shell | |
| 48 | + | // exits. Zero means the defaults above. | |
| 49 | + | TermAfter, KillAfter, WaitDelay time.Duration | |
| 50 | + | ||
| 51 | + | // signal sends a signal to a process group; tests replace it to observe the | |
| 52 | + | // escalation. Default: syscall.Kill(-pgid, sig). | |
| 53 | + | signal func(pgid int, sig syscall.Signal) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func (c *ExecConfig) defaults() { | |
| 57 | + | if c.Shell == "" { | |
| 58 | + | c.Shell = "/bin/sh" | |
| 59 | + | } | |
| 60 | + | if c.ConnectTimeout == 0 { | |
| 61 | + | c.ConnectTimeout = 30 * time.Second | |
| 62 | + | } | |
| 63 | + | if c.RunTimeout == 0 { | |
| 64 | + | c.RunTimeout = 60 * time.Second | |
| 65 | + | } | |
| 66 | + | if c.TermAfter == 0 { | |
| 67 | + | c.TermAfter = DefaultTermAfter | |
| 68 | + | } | |
| 69 | + | if c.KillAfter == 0 { | |
| 70 | + | c.KillAfter = DefaultKillAfter | |
| 71 | + | } | |
| 72 | + | if c.WaitDelay == 0 { | |
| 73 | + | c.WaitDelay = DefaultWaitDelay | |
| 74 | + | } | |
| 75 | + | if c.signal == nil { | |
| 76 | + | c.signal = func(pgid int, sig syscall.Signal) { _ = syscall.Kill(-pgid, sig) } | |
| 77 | + | } | |
| 34 | 78 | } | |
| 35 | 79 | ||
| 36 | 80 | // Exec is `ermite-runner exec`, the CI-1 stub executor: it connects back to the | |
| 37 | 81 | // host, receives one Run, runs the script with the shell, streams stdout and | |
| 38 | 82 | // stderr as Output frames, and ends with an Exit frame. It returns the exit code | |
| 39 | - | // it reported. A Cancel frame sends SIGINT to the script's process group. | |
| 83 | + | // it reported. A Cancel frame, or the end of ctx, stops the script's process | |
| 84 | + | // group with escalating signals (see ExecConfig). | |
| 40 | 85 | func Exec(ctx context.Context, cfg ExecConfig) (int, error) { | |
| 41 | - | if cfg.Shell == "" { | |
| 42 | - | cfg.Shell = "/bin/sh" | |
| 43 | - | } | |
| 44 | - | if cfg.ConnectTimeout == 0 { | |
| 45 | - | cfg.ConnectTimeout = 30 * time.Second | |
| 46 | - | } | |
| 47 | - | if cfg.RunTimeout == 0 { | |
| 48 | - | cfg.RunTimeout = 60 * time.Second | |
| 49 | - | } | |
| 86 | + | cfg.defaults() | |
| 50 | 87 | raw, err := cfg.Target.Dial(ctx, cfg.ConnectTimeout) | |
| 51 | 88 | if err != nil { | |
| 52 | 89 | return 1, err | |
| @@ -55,6 +92,85 @@ func Exec(ctx context.Context, cfg ExecConfig) (int, error) { | |||
| 55 | 92 | return serve(ctx, raw, cfg) | |
| 56 | 93 | } | |
| 57 | 94 | ||
| 95 | + | // frameWriter turns writes into Output frames of at most ChunkSize bytes. It is | |
| 96 | + | // the command's Stdout/Stderr, so no goroutine of ours sits between the pipe | |
| 97 | + | // and the stream. | |
| 98 | + | type frameWriter struct { | |
| 99 | + | conn *execstream.Conn | |
| 100 | + | kind execpb.StreamKind | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func (w frameWriter) Write(p []byte) (int, error) { | |
| 104 | + | n := 0 | |
| 105 | + | for len(p) > 0 { | |
| 106 | + | chunk := p | |
| 107 | + | if len(chunk) > execstream.ChunkSize { | |
| 108 | + | chunk = chunk[:execstream.ChunkSize] | |
| 109 | + | } | |
| 110 | + | err := w.conn.Send(&execpb.Frame{Body: &execpb.Frame_Output{Output: &execpb.Output{ | |
| 111 | + | Stream: w.kind, Data: append([]byte(nil), chunk...), UnixNano: time.Now().UnixNano(), | |
| 112 | + | }}}) | |
| 113 | + | if err != nil { | |
| 114 | + | return n, err | |
| 115 | + | } | |
| 116 | + | n += len(chunk) | |
| 117 | + | p = p[len(chunk):] | |
| 118 | + | } | |
| 119 | + | return n, nil | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // escalator signals a process group and escalates after a cancel. Once finish | |
| 123 | + | // has been called (the shell was reaped) it never signals again: the group id | |
| 124 | + | // could have been reused by then. | |
| 125 | + | type escalator struct { | |
| 126 | + | cfg ExecConfig | |
| 127 | + | pgid int | |
| 128 | + | ||
| 129 | + | mu sync.Mutex | |
| 130 | + | finished bool | |
| 131 | + | started bool | |
| 132 | + | timers []*time.Timer | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func (e *escalator) send(sig syscall.Signal) { | |
| 136 | + | e.mu.Lock() | |
| 137 | + | defer e.mu.Unlock() | |
| 138 | + | if !e.finished { | |
| 139 | + | e.cfg.signal(e.pgid, sig) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // cancel starts the escalation; only the first call does anything. | |
| 144 | + | func (e *escalator) cancel() { | |
| 145 | + | e.mu.Lock() | |
| 146 | + | if e.finished || e.started { | |
| 147 | + | e.mu.Unlock() | |
| 148 | + | return | |
| 149 | + | } | |
| 150 | + | e.started = true | |
| 151 | + | e.cfg.signal(e.pgid, syscall.SIGINT) | |
| 152 | + | e.timers = append(e.timers, | |
| 153 | + | time.AfterFunc(e.cfg.TermAfter, func() { e.send(syscall.SIGTERM) }), | |
| 154 | + | time.AfterFunc(e.cfg.TermAfter+e.cfg.KillAfter, func() { e.send(syscall.SIGKILL) }), | |
| 155 | + | ) | |
| 156 | + | e.mu.Unlock() | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // finish is called right after the shell was reaped: it kills what is left of | |
| 160 | + | // the group (background children), stops the timers and forbids further signals. | |
| 161 | + | func (e *escalator) finish() { | |
| 162 | + | e.mu.Lock() | |
| 163 | + | defer e.mu.Unlock() | |
| 164 | + | if e.finished { | |
| 165 | + | return | |
| 166 | + | } | |
| 167 | + | e.cfg.signal(e.pgid, syscall.SIGKILL) | |
| 168 | + | e.finished = true | |
| 169 | + | for _, t := range e.timers { | |
| 170 | + | t.Stop() | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 58 | 174 | func serve(ctx context.Context, rw io.ReadWriteCloser, cfg ExecConfig) (int, error) { | |
| 59 | 175 | conn := execstream.NewConn(rw) | |
| 60 | 176 | host, _ := os.Hostname() | |
| @@ -96,58 +212,43 @@ func serve(ctx context.Context, rw io.ReadWriteCloser, cfg ExecConfig) (int, err | |||
| 96 | 212 | cmd.Env = append(cmd.Env, k+"="+v) | |
| 97 | 213 | } | |
| 98 | 214 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |
| 99 | - | stdout, err := cmd.StdoutPipe() | |
| 100 | - | if err != nil { | |
| 101 | - | return sendExit(conn, 1, err) | |
| 102 | - | } | |
| 103 | - | stderr, err := cmd.StderrPipe() | |
| 104 | - | if err != nil { | |
| 105 | - | return sendExit(conn, 1, err) | |
| 106 | - | } | |
| 215 | + | // Frame writers as stdout/stderr: exec copies the pipes for us, and | |
| 216 | + | // WaitDelay bounds the wait when a background child keeps a pipe open. | |
| 217 | + | cmd.Stdout = frameWriter{conn, execpb.StreamKind_STREAM_KIND_STDOUT} | |
| 218 | + | cmd.Stderr = frameWriter{conn, execpb.StreamKind_STREAM_KIND_STDERR} | |
| 219 | + | cmd.WaitDelay = cfg.WaitDelay | |
| 107 | 220 | if err := cmd.Start(); err != nil { | |
| 108 | 221 | return sendExit(conn, 127, fmt.Errorf("start %s: %w", cfg.Shell, err)) | |
| 109 | 222 | } | |
| 223 | + | esc := &escalator{cfg: cfg, pgid: cmd.Process.Pid} | |
| 110 | 224 | ||
| 111 | - | // Cancel frames arrive while the script runs. | |
| 225 | + | // Frames from the host while the script runs: a Cancel starts the escalation; | |
| 226 | + | // a lost host kills the script rather than let it run unobserved. | |
| 112 | 227 | go func() { | |
| 113 | 228 | for { | |
| 114 | 229 | f, err := conn.Recv() | |
| 115 | 230 | if err != nil { | |
| 116 | - | // Host went away: stop the script rather than run unobserved. | |
| 117 | - | _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) | |
| 231 | + | esc.send(syscall.SIGKILL) | |
| 118 | 232 | return | |
| 119 | 233 | } | |
| 120 | 234 | if f.GetCancel() != nil { | |
| 121 | - | _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGINT) | |
| 235 | + | esc.cancel() | |
| 122 | 236 | } | |
| 123 | 237 | } | |
| 124 | 238 | }() | |
| 239 | + | // The executor's own context (SIGTERM forwarded by init) cancels the same way. | |
| 240 | + | stopCtx := context.AfterFunc(ctx, esc.cancel) | |
| 241 | + | defer stopCtx() | |
| 125 | 242 | ||
| 126 | - | var wg sync.WaitGroup | |
| 127 | - | pump := func(r io.Reader, kind execpb.StreamKind) { | |
| 128 | - | defer wg.Done() | |
| 129 | - | buf := make([]byte, execstream.ChunkSize) | |
| 130 | - | for { | |
| 131 | - | n, err := r.Read(buf) | |
| 132 | - | if n > 0 { | |
| 133 | - | _ = conn.Send(&execpb.Frame{Body: &execpb.Frame_Output{Output: &execpb.Output{ | |
| 134 | - | Stream: kind, Data: append([]byte(nil), buf[:n]...), UnixNano: time.Now().UnixNano(), | |
| 135 | - | }}}) | |
| 136 | - | } | |
| 137 | - | if err != nil { | |
| 138 | - | return | |
| 139 | - | } | |
| 140 | - | } | |
| 141 | - | } | |
| 142 | - | wg.Add(2) | |
| 143 | - | go pump(stdout, execpb.StreamKind_STREAM_KIND_STDOUT) | |
| 144 | - | go pump(stderr, execpb.StreamKind_STREAM_KIND_STDERR) | |
| 145 | - | wg.Wait() // pipes must be drained before Wait closes them | |
| 146 | 243 | waitErr := cmd.Wait() | |
| 244 | + | esc.finish() | |
| 245 | + | ||
| 147 | 246 | code := 0 | |
| 148 | 247 | var ee *exec.ExitError | |
| 149 | 248 | switch { | |
| 150 | - | case waitErr == nil: | |
| 249 | + | case waitErr == nil, errors.Is(waitErr, exec.ErrWaitDelay): | |
| 250 | + | // ErrWaitDelay: the shell exited cleanly but a background child kept the | |
| 251 | + | // pipes open; the group was killed above. | |
| 151 | 252 | case errors.As(waitErr, &ee): | |
| 152 | 253 | code = ee.ExitCode() | |
| 153 | 254 | if code < 0 { // killed by a signal: 128+signal like a shell | |
| @@ -20,6 +20,17 @@ import ( | |||||
| 20 | // DefaultScript is what the stub executor runs when the host sends no script. | 20 | // DefaultScript is what the stub executor runs when the host sends no script. | ||
| 21 | const DefaultScript = "uname -a; id; cat /etc/os-release; docker info" | 21 | const DefaultScript = "uname -a; id; cat /etc/os-release; docker info" | ||
| 22 | 22 | ||||
| 23 | + | // Cancellation timings, like the official runner's ProcessInvoker: SIGINT, then | |||
| 24 | + | // SIGTERM after 7.5 s, then SIGKILL 2.5 s later. | |||
| 25 | + | const ( | |||
| 26 | + | DefaultTermAfter = 7500 * time.Millisecond | |||
| 27 | + | DefaultKillAfter = 2500 * time.Millisecond | |||
| 28 | + | // DefaultWaitDelay bounds how long the executor waits for the output pipes | |||
| 29 | + | // to close after the script's shell exited (background children may hold | |||
| 30 | + | // them open). | |||
| 31 | + | DefaultWaitDelay = 500 * time.Millisecond | |||
| 32 | + | ) | |||
| 33 | + | ||||
| 23 | // ExecConfig configures the executor. | 34 | // ExecConfig configures the executor. | ||
| 24 | type ExecConfig struct { | 35 | type ExecConfig struct { | ||
| 25 | Target Target | 36 | Target Target | ||
| @@ -31,22 +42,48 @@ type ExecConfig struct { | |||||
| 31 | ConnectTimeout time.Duration | 42 | ConnectTimeout time.Duration | ||
| 32 | // RunTimeout bounds the wait for the host's Run frame (default 60 s). | 43 | // RunTimeout bounds the wait for the host's Run frame (default 60 s). | ||
| 33 | RunTimeout time.Duration | 44 | RunTimeout time.Duration | ||
| 45 | + | // TermAfter and KillAfter are the escalation delays after a Cancel (or after | |||
| 46 | + | // the executor's own context ends): SIGINT at once, SIGTERM after TermAfter, | |||
| 47 | + | // SIGKILL KillAfter later. WaitDelay is the pipe-drain grace after the shell | |||
| 48 | + | // exits. Zero means the defaults above. | |||
| 49 | + | TermAfter, KillAfter, WaitDelay time.Duration | |||
| 50 | + | ||||
| 51 | + | // signal sends a signal to a process group; tests replace it to observe the | |||
| 52 | + | // escalation. Default: syscall.Kill(-pgid, sig). | |||
| 53 | + | signal func(pgid int, sig syscall.Signal) | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func (c *ExecConfig) defaults() { | |||
| 57 | + | if c.Shell == "" { | |||
| 58 | + | c.Shell = "/bin/sh" | |||
| 59 | + | } | |||
| 60 | + | if c.ConnectTimeout == 0 { | |||
| 61 | + | c.ConnectTimeout = 30 * time.Second | |||
| 62 | + | } | |||
| 63 | + | if c.RunTimeout == 0 { | |||
| 64 | + | c.RunTimeout = 60 * time.Second | |||
| 65 | + | } | |||
| 66 | + | if c.TermAfter == 0 { | |||
| 67 | + | c.TermAfter = DefaultTermAfter | |||
| 68 | + | } | |||
| 69 | + | if c.KillAfter == 0 { | |||
| 70 | + | c.KillAfter = DefaultKillAfter | |||
| 71 | + | } | |||
| 72 | + | if c.WaitDelay == 0 { | |||
| 73 | + | c.WaitDelay = DefaultWaitDelay | |||
| 74 | + | } | |||
| 75 | + | if c.signal == nil { | |||
| 76 | + | c.signal = func(pgid int, sig syscall.Signal) { _ = syscall.Kill(-pgid, sig) } | |||
| 77 | + | } | |||
| 34 | } | 78 | } | ||
| 35 | 79 | ||||
| 36 | // Exec is `ermite-runner exec`, the CI-1 stub executor: it connects back to the | 80 | // Exec is `ermite-runner exec`, the CI-1 stub executor: it connects back to the | ||
| 37 | // host, receives one Run, runs the script with the shell, streams stdout and | 81 | // host, receives one Run, runs the script with the shell, streams stdout and | ||
| 38 | // stderr as Output frames, and ends with an Exit frame. It returns the exit code | 82 | // stderr as Output frames, and ends with an Exit frame. It returns the exit code | ||
| 39 | - | // it reported. A Cancel frame sends SIGINT to the script's process group. | 83 | + | // it reported. A Cancel frame, or the end of ctx, stops the script's process |
| 84 | + | // group with escalating signals (see ExecConfig). | |||
| 40 | func Exec(ctx context.Context, cfg ExecConfig) (int, error) { | 85 | func Exec(ctx context.Context, cfg ExecConfig) (int, error) { | ||
| 41 | - | if cfg.Shell == "" { | 86 | + | cfg.defaults() |
| 42 | - | cfg.Shell = "/bin/sh" | |||
| 43 | - | } | |||
| 44 | - | if cfg.ConnectTimeout == 0 { | |||
| 45 | - | cfg.ConnectTimeout = 30 * time.Second | |||
| 46 | - | } | |||
| 47 | - | if cfg.RunTimeout == 0 { | |||
| 48 | - | cfg.RunTimeout = 60 * time.Second | |||
| 49 | - | } | |||
| 50 | raw, err := cfg.Target.Dial(ctx, cfg.ConnectTimeout) | 87 | raw, err := cfg.Target.Dial(ctx, cfg.ConnectTimeout) | ||
| 51 | if err != nil { | 88 | if err != nil { | ||
| 52 | return 1, err | 89 | return 1, err | ||
| @@ -55,6 +92,85 @@ func Exec(ctx context.Context, cfg ExecConfig) (int, error) { | |||||
| 55 | return serve(ctx, raw, cfg) | 92 | return serve(ctx, raw, cfg) | ||
| 56 | } | 93 | } | ||
| 57 | 94 | ||||
| 95 | + | // frameWriter turns writes into Output frames of at most ChunkSize bytes. It is | |||
| 96 | + | // the command's Stdout/Stderr, so no goroutine of ours sits between the pipe | |||
| 97 | + | // and the stream. | |||
| 98 | + | type frameWriter struct { | |||
| 99 | + | conn *execstream.Conn | |||
| 100 | + | kind execpb.StreamKind | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func (w frameWriter) Write(p []byte) (int, error) { | |||
| 104 | + | n := 0 | |||
| 105 | + | for len(p) > 0 { | |||
| 106 | + | chunk := p | |||
| 107 | + | if len(chunk) > execstream.ChunkSize { | |||
| 108 | + | chunk = chunk[:execstream.ChunkSize] | |||
| 109 | + | } | |||
| 110 | + | err := w.conn.Send(&execpb.Frame{Body: &execpb.Frame_Output{Output: &execpb.Output{ | |||
| 111 | + | Stream: w.kind, Data: append([]byte(nil), chunk...), UnixNano: time.Now().UnixNano(), | |||
| 112 | + | }}}) | |||
| 113 | + | if err != nil { | |||
| 114 | + | return n, err | |||
| 115 | + | } | |||
| 116 | + | n += len(chunk) | |||
| 117 | + | p = p[len(chunk):] | |||
| 118 | + | } | |||
| 119 | + | return n, nil | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // escalator signals a process group and escalates after a cancel. Once finish | |||
| 123 | + | // has been called (the shell was reaped) it never signals again: the group id | |||
| 124 | + | // could have been reused by then. | |||
| 125 | + | type escalator struct { | |||
| 126 | + | cfg ExecConfig | |||
| 127 | + | pgid int | |||
| 128 | + | ||||
| 129 | + | mu sync.Mutex | |||
| 130 | + | finished bool | |||
| 131 | + | started bool | |||
| 132 | + | timers []*time.Timer | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func (e *escalator) send(sig syscall.Signal) { | |||
| 136 | + | e.mu.Lock() | |||
| 137 | + | defer e.mu.Unlock() | |||
| 138 | + | if !e.finished { | |||
| 139 | + | e.cfg.signal(e.pgid, sig) | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // cancel starts the escalation; only the first call does anything. | |||
| 144 | + | func (e *escalator) cancel() { | |||
| 145 | + | e.mu.Lock() | |||
| 146 | + | if e.finished || e.started { | |||
| 147 | + | e.mu.Unlock() | |||
| 148 | + | return | |||
| 149 | + | } | |||
| 150 | + | e.started = true | |||
| 151 | + | e.cfg.signal(e.pgid, syscall.SIGINT) | |||
| 152 | + | e.timers = append(e.timers, | |||
| 153 | + | time.AfterFunc(e.cfg.TermAfter, func() { e.send(syscall.SIGTERM) }), | |||
| 154 | + | time.AfterFunc(e.cfg.TermAfter+e.cfg.KillAfter, func() { e.send(syscall.SIGKILL) }), | |||
| 155 | + | ) | |||
| 156 | + | e.mu.Unlock() | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // finish is called right after the shell was reaped: it kills what is left of | |||
| 160 | + | // the group (background children), stops the timers and forbids further signals. | |||
| 161 | + | func (e *escalator) finish() { | |||
| 162 | + | e.mu.Lock() | |||
| 163 | + | defer e.mu.Unlock() | |||
| 164 | + | if e.finished { | |||
| 165 | + | return | |||
| 166 | + | } | |||
| 167 | + | e.cfg.signal(e.pgid, syscall.SIGKILL) | |||
| 168 | + | e.finished = true | |||
| 169 | + | for _, t := range e.timers { | |||
| 170 | + | t.Stop() | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | ||||
| 58 | func serve(ctx context.Context, rw io.ReadWriteCloser, cfg ExecConfig) (int, error) { | 174 | func serve(ctx context.Context, rw io.ReadWriteCloser, cfg ExecConfig) (int, error) { | ||
| 59 | conn := execstream.NewConn(rw) | 175 | conn := execstream.NewConn(rw) | ||
| 60 | host, _ := os.Hostname() | 176 | host, _ := os.Hostname() | ||
| @@ -96,58 +212,43 @@ func serve(ctx context.Context, rw io.ReadWriteCloser, cfg ExecConfig) (int, err | |||||
| 96 | cmd.Env = append(cmd.Env, k+"="+v) | 212 | cmd.Env = append(cmd.Env, k+"="+v) | ||
| 97 | } | 213 | } | ||
| 98 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | 214 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | ||
| 99 | - | stdout, err := cmd.StdoutPipe() | 215 | + | // Frame writers as stdout/stderr: exec copies the pipes for us, and |
| 100 | - | if err != nil { | 216 | + | // WaitDelay bounds the wait when a background child keeps a pipe open. |
| 101 | - | return sendExit(conn, 1, err) | 217 | + | cmd.Stdout = frameWriter{conn, execpb.StreamKind_STREAM_KIND_STDOUT} |
| 102 | - | } | 218 | + | cmd.Stderr = frameWriter{conn, execpb.StreamKind_STREAM_KIND_STDERR} |
| 103 | - | stderr, err := cmd.StderrPipe() | 219 | + | cmd.WaitDelay = cfg.WaitDelay |
| 104 | - | if err != nil { | |||
| 105 | - | return sendExit(conn, 1, err) | |||
| 106 | - | } | |||
| 107 | if err := cmd.Start(); err != nil { | 220 | if err := cmd.Start(); err != nil { | ||
| 108 | return sendExit(conn, 127, fmt.Errorf("start %s: %w", cfg.Shell, err)) | 221 | return sendExit(conn, 127, fmt.Errorf("start %s: %w", cfg.Shell, err)) | ||
| 109 | } | 222 | } | ||
| 223 | + | esc := &escalator{cfg: cfg, pgid: cmd.Process.Pid} | |||
| 110 | 224 | ||||
| 111 | - | // Cancel frames arrive while the script runs. | 225 | + | // Frames from the host while the script runs: a Cancel starts the escalation; |
| 226 | + | // a lost host kills the script rather than let it run unobserved. | |||
| 112 | go func() { | 227 | go func() { | ||
| 113 | for { | 228 | for { | ||
| 114 | f, err := conn.Recv() | 229 | f, err := conn.Recv() | ||
| 115 | if err != nil { | 230 | if err != nil { | ||
| 116 | - | // Host went away: stop the script rather than run unobserved. | 231 | + | esc.send(syscall.SIGKILL) |
| 117 | - | _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) | |||
| 118 | return | 232 | return | ||
| 119 | } | 233 | } | ||
| 120 | if f.GetCancel() != nil { | 234 | if f.GetCancel() != nil { | ||
| 121 | - | _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGINT) | 235 | + | esc.cancel() |
| 122 | } | 236 | } | ||
| 123 | } | 237 | } | ||
| 124 | }() | 238 | }() | ||
| 239 | + | // The executor's own context (SIGTERM forwarded by init) cancels the same way. | |||
| 240 | + | stopCtx := context.AfterFunc(ctx, esc.cancel) | |||
| 241 | + | defer stopCtx() | |||
| 125 | 242 | ||||
| 126 | - | var wg sync.WaitGroup | |||
| 127 | - | pump := func(r io.Reader, kind execpb.StreamKind) { | |||
| 128 | - | defer wg.Done() | |||
| 129 | - | buf := make([]byte, execstream.ChunkSize) | |||
| 130 | - | for { | |||
| 131 | - | n, err := r.Read(buf) | |||
| 132 | - | if n > 0 { | |||
| 133 | - | _ = conn.Send(&execpb.Frame{Body: &execpb.Frame_Output{Output: &execpb.Output{ | |||
| 134 | - | Stream: kind, Data: append([]byte(nil), buf[:n]...), UnixNano: time.Now().UnixNano(), | |||
| 135 | - | }}}) | |||
| 136 | - | } | |||
| 137 | - | if err != nil { | |||
| 138 | - | return | |||
| 139 | - | } | |||
| 140 | - | } | |||
| 141 | - | } | |||
| 142 | - | wg.Add(2) | |||
| 143 | - | go pump(stdout, execpb.StreamKind_STREAM_KIND_STDOUT) | |||
| 144 | - | go pump(stderr, execpb.StreamKind_STREAM_KIND_STDERR) | |||
| 145 | - | wg.Wait() // pipes must be drained before Wait closes them | |||
| 146 | waitErr := cmd.Wait() | 243 | waitErr := cmd.Wait() | ||
| 244 | + | esc.finish() | |||
| 245 | + | ||||
| 147 | code := 0 | 246 | code := 0 | ||
| 148 | var ee *exec.ExitError | 247 | var ee *exec.ExitError | ||
| 149 | switch { | 248 | switch { | ||
| 150 | - | case waitErr == nil: | 249 | + | case waitErr == nil, errors.Is(waitErr, exec.ErrWaitDelay): |
| 250 | + | // ErrWaitDelay: the shell exited cleanly but a background child kept the | |||
| 251 | + | // pipes open; the group was killed above. | |||
| 151 | case errors.As(waitErr, &ee): | 252 | case errors.As(waitErr, &ee): | ||
| 152 | code = ee.ExitCode() | 253 | code = ee.ExitCode() | ||
| 153 | if code < 0 { // killed by a signal: 128+signal like a shell | 254 | if code < 0 { // killed by a signal: 128+signal like a shell | ||
M internal/guest/guest_test.go +172 -22
| @@ -4,10 +4,15 @@ package guest | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "fmt" | |
| 7 | 8 | "net" | |
| 9 | + | "os" | |
| 8 | 10 | "path/filepath" | |
| 9 | 11 | "reflect" | |
| 12 | + | "strconv" | |
| 10 | 13 | "strings" | |
| 14 | + | "sync" | |
| 15 | + | "sync/atomic" | |
| 11 | 16 | "syscall" | |
| 12 | 17 | "testing" | |
| 13 | 18 | "time" | |
| @@ -45,7 +50,7 @@ func TestParseTarget(t *testing.T) { | |||
| 45 | 50 | } | |
| 46 | 51 | ||
| 47 | 52 | // host plays the host process on a unix socket and runs one session. | |
| 48 | - | func host(t *testing.T, script string, ctxFn func(context.Context) context.Context) (out, errOut string, exit *execpb.Exit, hello *execpb.Hello, code int, execErr error) { | |
| 53 | + | func host(t *testing.T, script string, cancelAfter time.Duration, tweak func(*ExecConfig)) (out, errOut string, exit *execpb.Exit, hello *execpb.Hello, code int, execErr error) { | |
| 49 | 54 | t.Helper() | |
| 50 | 55 | sock := filepath.Join(t.TempDir(), "s") | |
| 51 | 56 | ln, err := net.Listen("unix", sock) | |
| @@ -59,7 +64,11 @@ func host(t *testing.T, script string, ctxFn func(context.Context) context.Conte | |||
| 59 | 64 | } | |
| 60 | 65 | done := make(chan res, 1) | |
| 61 | 66 | go func() { | |
| 62 | - | c, err := Exec(context.Background(), ExecConfig{Target: Target{"unix", sock}, Sandbox: "container", Version: "test", Shell: "/bin/sh", RunTimeout: 5 * time.Second}) | |
| 67 | + | cfg := ExecConfig{Target: Target{"unix", sock}, Sandbox: "container", Version: "test", Shell: "/bin/sh", RunTimeout: 5 * time.Second} | |
| 68 | + | if tweak != nil { | |
| 69 | + | tweak(&cfg) | |
| 70 | + | } | |
| 71 | + | c, err := Exec(context.Background(), cfg) | |
| 63 | 72 | done <- res{c, err} | |
| 64 | 73 | }() | |
| 65 | 74 | conn, err := ln.Accept() | |
| @@ -67,12 +76,13 @@ func host(t *testing.T, script string, ctxFn func(context.Context) context.Conte | |||
| 67 | 76 | t.Fatal(err) | |
| 68 | 77 | } | |
| 69 | 78 | defer func() { _ = conn.Close() }() | |
| 70 | - | ctx := context.Background() | |
| 71 | - | if ctxFn != nil { | |
| 72 | - | ctx = ctxFn(ctx) | |
| 79 | + | var cancel chan *execpb.Cancel | |
| 80 | + | if cancelAfter > 0 { | |
| 81 | + | cancel = make(chan *execpb.Cancel, 1) | |
| 82 | + | time.AfterFunc(cancelAfter, func() { cancel <- &execpb.Cancel{Reason: "test"} }) | |
| 73 | 83 | } | |
| 74 | 84 | var so, se strings.Builder | |
| 75 | - | hello, exit, err = execstream.Drive(ctx, conn, &execpb.Run{Script: script, Env: map[string]string{"STUB_VAR": "from-host"}}, func(o *execpb.Output) { | |
| 85 | + | hello, exit, err = execstream.Drive(conn, &execpb.Run{Script: script, Env: map[string]string{"STUB_VAR": "from-host"}}, cancel, func(o *execpb.Output) { | |
| 76 | 86 | if o.Stream == execpb.StreamKind_STREAM_KIND_STDERR { | |
| 77 | 87 | se.Write(o.Data) | |
| 78 | 88 | } else { | |
| @@ -87,7 +97,7 @@ func host(t *testing.T, script string, ctxFn func(context.Context) context.Conte | |||
| 87 | 97 | } | |
| 88 | 98 | ||
| 89 | 99 | func TestExecStreamsOutputAndExitCode(t *testing.T) { | |
| 90 | - | out, errOut, exit, hello, code, err := host(t, `echo out-line; echo err-line >&2; echo "$STUB_VAR"; exit 3`, nil) | |
| 100 | + | out, errOut, exit, hello, code, err := host(t, `echo out-line; echo err-line >&2; echo "$STUB_VAR"; exit 3`, 0, nil) | |
| 91 | 101 | if err != nil { | |
| 92 | 102 | t.Fatal(err) | |
| 93 | 103 | } | |
| @@ -105,7 +115,7 @@ func TestExecStreamsOutputAndExitCode(t *testing.T) { | |||
| 105 | 115 | func TestExecDefaultScriptRunsDiagnostics(t *testing.T) { | |
| 106 | 116 | // The default script is the CI-1 stub: uname, id, os-release, docker info. | |
| 107 | 117 | // docker may be absent here: only the layout of the output is checked. | |
| 108 | - | out, errOut, exit, _, _, _ := host(t, "", nil) | |
| 118 | + | out, errOut, exit, _, _, _ := host(t, "", 0, nil) | |
| 109 | 119 | if !strings.Contains(out, "Linux") || !strings.Contains(out, "uid=") { | |
| 110 | 120 | t.Fatalf("uname/id missing: out=%q err=%q", out, errOut) | |
| 111 | 121 | } | |
| @@ -118,7 +128,7 @@ func TestExecDefaultScriptRunsDiagnostics(t *testing.T) { | |||
| 118 | 128 | } | |
| 119 | 129 | ||
| 120 | 130 | func TestExecLargeOutputIsChunkedAndComplete(t *testing.T) { | |
| 121 | - | out, _, exit, _, _, _ := host(t, `head -c 300000 /dev/zero | tr '\0' 'x'`, nil) | |
| 131 | + | out, _, exit, _, _, _ := host(t, `head -c 300000 /dev/zero | tr '\0' 'x'`, 0, nil) | |
| 122 | 132 | if len(out) != 300000 || exit.Code != 0 { | |
| 123 | 133 | t.Fatalf("len=%d exit=%v", len(out), exit) | |
| 124 | 134 | } | |
| @@ -126,11 +136,7 @@ func TestExecLargeOutputIsChunkedAndComplete(t *testing.T) { | |||
| 126 | 136 | ||
| 127 | 137 | func TestExecCancelSendsSIGINT(t *testing.T) { | |
| 128 | 138 | start := time.Now() | |
| 129 | - | out, _, exit, _, _, _ := host(t, `trap 'echo got-int; exit 130' INT; echo ready; while :; do sleep 0.05; done`, func(ctx context.Context) context.Context { | |
| 130 | - | c, cancel := context.WithCancel(ctx) | |
| 131 | - | time.AfterFunc(500*time.Millisecond, cancel) | |
| 132 | - | return c | |
| 133 | - | }) | |
| 139 | + | out, _, exit, _, _, _ := host(t, `trap 'echo got-int; exit 130' INT; echo ready; while :; do sleep 0.05; done`, 500*time.Millisecond, nil) | |
| 134 | 140 | if !strings.Contains(out, "got-int") || exit.Code != 130 { | |
| 135 | 141 | t.Fatalf("out=%q exit=%v", out, exit) | |
| 136 | 142 | } | |
| @@ -139,8 +145,123 @@ func TestExecCancelSendsSIGINT(t *testing.T) { | |||
| 139 | 145 | } | |
| 140 | 146 | } | |
| 141 | 147 | ||
| 148 | + | // sigRecorder replaces the signal function: it records (signal, time since | |
| 149 | + | // start) and still delivers the signal. | |
| 150 | + | type sigRecorder struct { | |
| 151 | + | mu sync.Mutex | |
| 152 | + | start time.Time | |
| 153 | + | got []string | |
| 154 | + | times []time.Duration | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func (r *sigRecorder) fn(pgid int, sig syscall.Signal) { | |
| 158 | + | r.mu.Lock() | |
| 159 | + | r.got = append(r.got, sig.String()) | |
| 160 | + | r.times = append(r.times, time.Since(r.start)) | |
| 161 | + | r.mu.Unlock() | |
| 162 | + | _ = syscall.Kill(-pgid, sig) | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // C1: a script that ignores SIGINT and SIGTERM is stopped by the escalation: | |
| 166 | + | // SIGINT at once, SIGTERM after TermAfter, SIGKILL KillAfter later. | |
| 167 | + | func TestExecCancelEscalatesToKill(t *testing.T) { | |
| 168 | + | rec := &sigRecorder{start: time.Now()} | |
| 169 | + | termAfter, killAfter := 400*time.Millisecond, 300*time.Millisecond | |
| 170 | + | _, _, exit, _, _, _ := host(t, `trap '' INT TERM; echo ready; while :; do sleep 0.05; done`, 300*time.Millisecond, func(c *ExecConfig) { | |
| 171 | + | c.TermAfter, c.KillAfter, c.signal = termAfter, killAfter, rec.fn | |
| 172 | + | }) | |
| 173 | + | if exit.Code != 128+int32(syscall.SIGKILL) { | |
| 174 | + | t.Fatalf("a script ignoring INT and TERM must end by SIGKILL, exit=%v", exit) | |
| 175 | + | } | |
| 176 | + | rec.mu.Lock() | |
| 177 | + | defer rec.mu.Unlock() | |
| 178 | + | // SIGINT, SIGTERM, SIGKILL (from the escalation), then the final group kill | |
| 179 | + | // issued after the shell was reaped. | |
| 180 | + | if len(rec.got) < 3 || rec.got[0] != "interrupt" || rec.got[1] != "terminated" || rec.got[2] != "killed" { | |
| 181 | + | t.Fatalf("signals: %v", rec.got) | |
| 182 | + | } | |
| 183 | + | cancelAt := rec.times[0] | |
| 184 | + | if d := rec.times[1] - cancelAt; d < termAfter-50*time.Millisecond || d > termAfter+400*time.Millisecond { | |
| 185 | + | t.Errorf("SIGTERM %v after SIGINT, want about %v", d, termAfter) | |
| 186 | + | } | |
| 187 | + | if d := rec.times[2] - rec.times[1]; d < killAfter-50*time.Millisecond || d > killAfter+400*time.Millisecond { | |
| 188 | + | t.Errorf("SIGKILL %v after SIGTERM, want about %v", d, killAfter) | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // C1 + C9: a script that exits on SIGINT stops the escalation: the timers are | |
| 193 | + | // stopped when Wait returns, so no SIGTERM/SIGKILL follows, and nothing is | |
| 194 | + | // signalled after the shell was reaped except the final group sweep. | |
| 195 | + | func TestExecEscalationStopsWhenScriptExits(t *testing.T) { | |
| 196 | + | rec := &sigRecorder{start: time.Now()} | |
| 197 | + | _, _, exit, _, _, _ := host(t, `trap 'exit 130' INT; while :; do sleep 0.05; done`, 200*time.Millisecond, func(c *ExecConfig) { | |
| 198 | + | c.TermAfter, c.KillAfter, c.signal = 300*time.Millisecond, 300*time.Millisecond, rec.fn | |
| 199 | + | }) | |
| 200 | + | if exit.Code != 130 { | |
| 201 | + | t.Fatalf("exit=%v", exit) | |
| 202 | + | } | |
| 203 | + | time.Sleep(1200 * time.Millisecond) // well past TermAfter+KillAfter | |
| 204 | + | rec.mu.Lock() | |
| 205 | + | defer rec.mu.Unlock() | |
| 206 | + | if len(rec.got) != 2 || rec.got[0] != "interrupt" || rec.got[1] != "killed" { | |
| 207 | + | t.Fatalf("want SIGINT then the final sweep only, got %v", rec.got) | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // C9: after the shell was reaped the escalator never signals again, even if its | |
| 212 | + | // timers had fired concurrently. | |
| 213 | + | func TestEscalatorNeverSignalsAfterFinish(t *testing.T) { | |
| 214 | + | var n atomic.Int32 | |
| 215 | + | e := &escalator{cfg: ExecConfig{TermAfter: time.Millisecond, KillAfter: time.Millisecond, signal: func(int, syscall.Signal) { n.Add(1) }}, pgid: 1 << 30} | |
| 216 | + | e.cancel() | |
| 217 | + | e.finish() | |
| 218 | + | after := n.Load() | |
| 219 | + | time.Sleep(50 * time.Millisecond) | |
| 220 | + | e.send(syscall.SIGTERM) | |
| 221 | + | e.cancel() | |
| 222 | + | if n.Load() != after { | |
| 223 | + | t.Fatalf("signalled %d times after finish", n.Load()-after) | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | // C2: a background child that keeps stdout open must not hang the executor: it | |
| 228 | + | // reports the script's exit promptly and the child is killed. | |
| 229 | + | func TestExecBackgroundChildDoesNotHoldTheSession(t *testing.T) { | |
| 230 | + | marker := filepath.Join(t.TempDir(), "child.pid") | |
| 231 | + | start := time.Now() | |
| 232 | + | out, _, exit, _, _, _ := host(t, `sleep 30 & echo $! > `+marker+`; echo parent-done`, 0, func(c *ExecConfig) { c.WaitDelay = 300 * time.Millisecond }) | |
| 233 | + | if time.Since(start) > 5*time.Second { | |
| 234 | + | t.Fatalf("the session was held by a background child for %v", time.Since(start)) | |
| 235 | + | } | |
| 236 | + | if exit == nil || exit.Code != 0 || !strings.Contains(out, "parent-done") { | |
| 237 | + | t.Fatalf("exit=%v out=%q", exit, out) | |
| 238 | + | } | |
| 239 | + | b, err := os.ReadFile(marker) | |
| 240 | + | if err != nil { | |
| 241 | + | t.Fatal(err) | |
| 242 | + | } | |
| 243 | + | pid, _ := strconv.Atoi(strings.TrimSpace(string(b))) | |
| 244 | + | deadline := time.Now().Add(3 * time.Second) | |
| 245 | + | for time.Now().Before(deadline) { | |
| 246 | + | if st, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)); err != nil || strings.Contains(string(st), ") Z") { | |
| 247 | + | return | |
| 248 | + | } | |
| 249 | + | time.Sleep(50 * time.Millisecond) | |
| 250 | + | } | |
| 251 | + | t.Fatalf("background child %d survived the job", pid) | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | // C2: output written by the script before the shell exits is complete even when | |
| 255 | + | // the stream is large and a background child holds the pipe. | |
| 256 | + | func TestExecOutputCompleteWithBackgroundChild(t *testing.T) { | |
| 257 | + | out, _, exit, _, _, _ := host(t, `sleep 30 & head -c 200000 /dev/zero | tr '\0' 'y'`, 0, func(c *ExecConfig) { c.WaitDelay = 300 * time.Millisecond }) | |
| 258 | + | if exit.Code != 0 || len(out) != 200000 { | |
| 259 | + | t.Fatalf("len=%d exit=%v", len(out), exit) | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | ||
| 142 | 263 | func TestExecKilledBySignalReports128PlusSignal(t *testing.T) { | |
| 143 | - | _, _, exit, _, _, _ := host(t, `kill -TERM $$`, nil) | |
| 264 | + | _, _, exit, _, _, _ := host(t, `kill -TERM $$`, 0, nil) | |
| 144 | 265 | if exit.Code != 128+int32(syscall.SIGTERM) { | |
| 145 | 266 | t.Fatalf("exit=%v", exit) | |
| 146 | 267 | } | |
| @@ -156,7 +277,7 @@ func TestExecMissingShell(t *testing.T) { | |||
| 156 | 277 | go func() { | |
| 157 | 278 | c, _ := ln.Accept() | |
| 158 | 279 | defer func() { _ = c.Close() }() | |
| 159 | - | _, exit, _ := execstream.Drive(context.Background(), c, &execpb.Run{Script: "true"}, nil) | |
| 280 | + | _, exit, _ := execstream.Drive(c, &execpb.Run{Script: "true"}, nil, nil) | |
| 160 | 281 | if exit == nil || exit.Code != 127 || exit.Error == "" { | |
| 161 | 282 | t.Errorf("want 127 with an error message, got %v", exit) | |
| 162 | 283 | } | |
| @@ -233,14 +354,13 @@ func TestInitInContainerSkipsVMSetup(t *testing.T) { | |||
| 233 | 354 | return | |
| 234 | 355 | } | |
| 235 | 356 | defer func() { _ = c.Close() }() | |
| 236 | - | _, _, _ = execstream.Drive(context.Background(), c, &execpb.Run{Script: "exit 0"}, nil) | |
| 357 | + | _, _, _ = execstream.Drive(c, &execpb.Run{Script: "exit 0"}, nil, nil) | |
| 237 | 358 | }() | |
| 238 | 359 | code := Init(context.Background(), InitConfig{ | |
| 239 | - | Cmdline: "ermite.sandbox=vm ermite.connect=unix:" + sock, | |
| 240 | - | Getpid: func() int { return 4242 }, | |
| 241 | - | ExecArgs: []string{"/bin/sh", "-c", "exit 5"}, | |
| 242 | - | DockerTimeout: time.Millisecond, | |
| 243 | - | SkipDocker: true, | |
| 360 | + | Cmdline: "ermite.sandbox=vm ermite.connect=unix:" + sock, | |
| 361 | + | Getpid: func() int { return 4242 }, | |
| 362 | + | ExecArgs: []string{"/bin/sh", "-c", "exit 5"}, | |
| 363 | + | SkipDocker: true, | |
| 244 | 364 | }) | |
| 245 | 365 | // ExecArgs is a stand-in for the executor: its exit code is Init's. | |
| 246 | 366 | if code != 5 { | |
| @@ -259,3 +379,33 @@ func TestWriteResolvConfNeedsGateway(t *testing.T) { | |||
| 259 | 379 | t.Fatal("empty gateway accepted") | |
| 260 | 380 | } | |
| 261 | 381 | } | |
| 382 | + | ||
| 383 | + | // C11: init forwards SIGTERM to the executor and finishes with the executor's | |
| 384 | + | // own exit code, instead of dying with its child still running. | |
| 385 | + | func TestInitForwardsSIGTERMToTheExecutor(t *testing.T) { | |
| 386 | + | sigs := make(chan os.Signal, 1) | |
| 387 | + | done := make(chan int, 1) | |
| 388 | + | marker := filepath.Join(t.TempDir(), "got-term") | |
| 389 | + | go func() { | |
| 390 | + | done <- Init(context.Background(), InitConfig{ | |
| 391 | + | Cmdline: "ermite.sandbox=container ermite.connect=unix:/nonexistent", | |
| 392 | + | Getpid: func() int { return 4242 }, | |
| 393 | + | SkipDocker: true, | |
| 394 | + | Signals: sigs, | |
| 395 | + | ExecArgs: []string{"/bin/sh", "-c", `trap 'echo term > ` + marker + `; exit 9' TERM; while :; do sleep 0.05; done`}, | |
| 396 | + | }) | |
| 397 | + | }() | |
| 398 | + | time.Sleep(500 * time.Millisecond) | |
| 399 | + | sigs <- syscall.SIGTERM | |
| 400 | + | select { | |
| 401 | + | case code := <-done: | |
| 402 | + | if code != 9 { | |
| 403 | + | t.Fatalf("init must return the executor's exit code, got %d", code) | |
| 404 | + | } | |
| 405 | + | case <-time.After(10 * time.Second): | |
| 406 | + | t.Fatal("init did not finish after SIGTERM") | |
| 407 | + | } | |
| 408 | + | if _, err := os.Stat(marker); err != nil { | |
| 409 | + | t.Fatal("the executor never received SIGTERM") | |
| 410 | + | } | |
| 411 | + | } | |
| @@ -4,10 +4,15 @@ package guest | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | + | "fmt" | |||
| 7 | "net" | 8 | "net" | ||
| 9 | + | "os" | |||
| 8 | "path/filepath" | 10 | "path/filepath" | ||
| 9 | "reflect" | 11 | "reflect" | ||
| 12 | + | "strconv" | |||
| 10 | "strings" | 13 | "strings" | ||
| 14 | + | "sync" | |||
| 15 | + | "sync/atomic" | |||
| 11 | "syscall" | 16 | "syscall" | ||
| 12 | "testing" | 17 | "testing" | ||
| 13 | "time" | 18 | "time" | ||
| @@ -45,7 +50,7 @@ func TestParseTarget(t *testing.T) { | |||||
| 45 | } | 50 | } | ||
| 46 | 51 | ||||
| 47 | // host plays the host process on a unix socket and runs one session. | 52 | // host plays the host process on a unix socket and runs one session. | ||
| 48 | - | func host(t *testing.T, script string, ctxFn func(context.Context) context.Context) (out, errOut string, exit *execpb.Exit, hello *execpb.Hello, code int, execErr error) { | 53 | + | func host(t *testing.T, script string, cancelAfter time.Duration, tweak func(*ExecConfig)) (out, errOut string, exit *execpb.Exit, hello *execpb.Hello, code int, execErr error) { |
| 49 | t.Helper() | 54 | t.Helper() | ||
| 50 | sock := filepath.Join(t.TempDir(), "s") | 55 | sock := filepath.Join(t.TempDir(), "s") | ||
| 51 | ln, err := net.Listen("unix", sock) | 56 | ln, err := net.Listen("unix", sock) | ||
| @@ -59,7 +64,11 @@ func host(t *testing.T, script string, ctxFn func(context.Context) context.Conte | |||||
| 59 | } | 64 | } | ||
| 60 | done := make(chan res, 1) | 65 | done := make(chan res, 1) | ||
| 61 | go func() { | 66 | go func() { | ||
| 62 | - | c, err := Exec(context.Background(), ExecConfig{Target: Target{"unix", sock}, Sandbox: "container", Version: "test", Shell: "/bin/sh", RunTimeout: 5 * time.Second}) | 67 | + | cfg := ExecConfig{Target: Target{"unix", sock}, Sandbox: "container", Version: "test", Shell: "/bin/sh", RunTimeout: 5 * time.Second} |
| 68 | + | if tweak != nil { | |||
| 69 | + | tweak(&cfg) | |||
| 70 | + | } | |||
| 71 | + | c, err := Exec(context.Background(), cfg) | |||
| 63 | done <- res{c, err} | 72 | done <- res{c, err} | ||
| 64 | }() | 73 | }() | ||
| 65 | conn, err := ln.Accept() | 74 | conn, err := ln.Accept() | ||
| @@ -67,12 +76,13 @@ func host(t *testing.T, script string, ctxFn func(context.Context) context.Conte | |||||
| 67 | t.Fatal(err) | 76 | t.Fatal(err) | ||
| 68 | } | 77 | } | ||
| 69 | defer func() { _ = conn.Close() }() | 78 | defer func() { _ = conn.Close() }() | ||
| 70 | - | ctx := context.Background() | 79 | + | var cancel chan *execpb.Cancel |
| 71 | - | if ctxFn != nil { | 80 | + | if cancelAfter > 0 { |
| 72 | - | ctx = ctxFn(ctx) | 81 | + | cancel = make(chan *execpb.Cancel, 1) |
| 82 | + | time.AfterFunc(cancelAfter, func() { cancel <- &execpb.Cancel{Reason: "test"} }) | |||
| 73 | } | 83 | } | ||
| 74 | var so, se strings.Builder | 84 | var so, se strings.Builder | ||
| 75 | - | hello, exit, err = execstream.Drive(ctx, conn, &execpb.Run{Script: script, Env: map[string]string{"STUB_VAR": "from-host"}}, func(o *execpb.Output) { | 85 | + | hello, exit, err = execstream.Drive(conn, &execpb.Run{Script: script, Env: map[string]string{"STUB_VAR": "from-host"}}, cancel, func(o *execpb.Output) { |
| 76 | if o.Stream == execpb.StreamKind_STREAM_KIND_STDERR { | 86 | if o.Stream == execpb.StreamKind_STREAM_KIND_STDERR { | ||
| 77 | se.Write(o.Data) | 87 | se.Write(o.Data) | ||
| 78 | } else { | 88 | } else { | ||
| @@ -87,7 +97,7 @@ func host(t *testing.T, script string, ctxFn func(context.Context) context.Conte | |||||
| 87 | } | 97 | } | ||
| 88 | 98 | ||||
| 89 | func TestExecStreamsOutputAndExitCode(t *testing.T) { | 99 | func TestExecStreamsOutputAndExitCode(t *testing.T) { | ||
| 90 | - | out, errOut, exit, hello, code, err := host(t, `echo out-line; echo err-line >&2; echo "$STUB_VAR"; exit 3`, nil) | 100 | + | out, errOut, exit, hello, code, err := host(t, `echo out-line; echo err-line >&2; echo "$STUB_VAR"; exit 3`, 0, nil) |
| 91 | if err != nil { | 101 | if err != nil { | ||
| 92 | t.Fatal(err) | 102 | t.Fatal(err) | ||
| 93 | } | 103 | } | ||
| @@ -105,7 +115,7 @@ func TestExecStreamsOutputAndExitCode(t *testing.T) { | |||||
| 105 | func TestExecDefaultScriptRunsDiagnostics(t *testing.T) { | 115 | func TestExecDefaultScriptRunsDiagnostics(t *testing.T) { | ||
| 106 | // The default script is the CI-1 stub: uname, id, os-release, docker info. | 116 | // The default script is the CI-1 stub: uname, id, os-release, docker info. | ||
| 107 | // docker may be absent here: only the layout of the output is checked. | 117 | // docker may be absent here: only the layout of the output is checked. | ||
| 108 | - | out, errOut, exit, _, _, _ := host(t, "", nil) | 118 | + | out, errOut, exit, _, _, _ := host(t, "", 0, nil) |
| 109 | if !strings.Contains(out, "Linux") || !strings.Contains(out, "uid=") { | 119 | if !strings.Contains(out, "Linux") || !strings.Contains(out, "uid=") { | ||
| 110 | t.Fatalf("uname/id missing: out=%q err=%q", out, errOut) | 120 | t.Fatalf("uname/id missing: out=%q err=%q", out, errOut) | ||
| 111 | } | 121 | } | ||
| @@ -118,7 +128,7 @@ func TestExecDefaultScriptRunsDiagnostics(t *testing.T) { | |||||
| 118 | } | 128 | } | ||
| 119 | 129 | ||||
| 120 | func TestExecLargeOutputIsChunkedAndComplete(t *testing.T) { | 130 | func TestExecLargeOutputIsChunkedAndComplete(t *testing.T) { | ||
| 121 | - | out, _, exit, _, _, _ := host(t, `head -c 300000 /dev/zero | tr '\0' 'x'`, nil) | 131 | + | out, _, exit, _, _, _ := host(t, `head -c 300000 /dev/zero | tr '\0' 'x'`, 0, nil) |
| 122 | if len(out) != 300000 || exit.Code != 0 { | 132 | if len(out) != 300000 || exit.Code != 0 { | ||
| 123 | t.Fatalf("len=%d exit=%v", len(out), exit) | 133 | t.Fatalf("len=%d exit=%v", len(out), exit) | ||
| 124 | } | 134 | } | ||
| @@ -126,11 +136,7 @@ func TestExecLargeOutputIsChunkedAndComplete(t *testing.T) { | |||||
| 126 | 136 | ||||
| 127 | func TestExecCancelSendsSIGINT(t *testing.T) { | 137 | func TestExecCancelSendsSIGINT(t *testing.T) { | ||
| 128 | start := time.Now() | 138 | start := time.Now() | ||
| 129 | - | out, _, exit, _, _, _ := host(t, `trap 'echo got-int; exit 130' INT; echo ready; while :; do sleep 0.05; done`, func(ctx context.Context) context.Context { | 139 | + | out, _, exit, _, _, _ := host(t, `trap 'echo got-int; exit 130' INT; echo ready; while :; do sleep 0.05; done`, 500*time.Millisecond, nil) |
| 130 | - | c, cancel := context.WithCancel(ctx) | |||
| 131 | - | time.AfterFunc(500*time.Millisecond, cancel) | |||
| 132 | - | return c | |||
| 133 | - | }) | |||
| 134 | if !strings.Contains(out, "got-int") || exit.Code != 130 { | 140 | if !strings.Contains(out, "got-int") || exit.Code != 130 { | ||
| 135 | t.Fatalf("out=%q exit=%v", out, exit) | 141 | t.Fatalf("out=%q exit=%v", out, exit) | ||
| 136 | } | 142 | } | ||
| @@ -139,8 +145,123 @@ func TestExecCancelSendsSIGINT(t *testing.T) { | |||||
| 139 | } | 145 | } | ||
| 140 | } | 146 | } | ||
| 141 | 147 | ||||
| 148 | + | // sigRecorder replaces the signal function: it records (signal, time since | |||
| 149 | + | // start) and still delivers the signal. | |||
| 150 | + | type sigRecorder struct { | |||
| 151 | + | mu sync.Mutex | |||
| 152 | + | start time.Time | |||
| 153 | + | got []string | |||
| 154 | + | times []time.Duration | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func (r *sigRecorder) fn(pgid int, sig syscall.Signal) { | |||
| 158 | + | r.mu.Lock() | |||
| 159 | + | r.got = append(r.got, sig.String()) | |||
| 160 | + | r.times = append(r.times, time.Since(r.start)) | |||
| 161 | + | r.mu.Unlock() | |||
| 162 | + | _ = syscall.Kill(-pgid, sig) | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // C1: a script that ignores SIGINT and SIGTERM is stopped by the escalation: | |||
| 166 | + | // SIGINT at once, SIGTERM after TermAfter, SIGKILL KillAfter later. | |||
| 167 | + | func TestExecCancelEscalatesToKill(t *testing.T) { | |||
| 168 | + | rec := &sigRecorder{start: time.Now()} | |||
| 169 | + | termAfter, killAfter := 400*time.Millisecond, 300*time.Millisecond | |||
| 170 | + | _, _, exit, _, _, _ := host(t, `trap '' INT TERM; echo ready; while :; do sleep 0.05; done`, 300*time.Millisecond, func(c *ExecConfig) { | |||
| 171 | + | c.TermAfter, c.KillAfter, c.signal = termAfter, killAfter, rec.fn | |||
| 172 | + | }) | |||
| 173 | + | if exit.Code != 128+int32(syscall.SIGKILL) { | |||
| 174 | + | t.Fatalf("a script ignoring INT and TERM must end by SIGKILL, exit=%v", exit) | |||
| 175 | + | } | |||
| 176 | + | rec.mu.Lock() | |||
| 177 | + | defer rec.mu.Unlock() | |||
| 178 | + | // SIGINT, SIGTERM, SIGKILL (from the escalation), then the final group kill | |||
| 179 | + | // issued after the shell was reaped. | |||
| 180 | + | if len(rec.got) < 3 || rec.got[0] != "interrupt" || rec.got[1] != "terminated" || rec.got[2] != "killed" { | |||
| 181 | + | t.Fatalf("signals: %v", rec.got) | |||
| 182 | + | } | |||
| 183 | + | cancelAt := rec.times[0] | |||
| 184 | + | if d := rec.times[1] - cancelAt; d < termAfter-50*time.Millisecond || d > termAfter+400*time.Millisecond { | |||
| 185 | + | t.Errorf("SIGTERM %v after SIGINT, want about %v", d, termAfter) | |||
| 186 | + | } | |||
| 187 | + | if d := rec.times[2] - rec.times[1]; d < killAfter-50*time.Millisecond || d > killAfter+400*time.Millisecond { | |||
| 188 | + | t.Errorf("SIGKILL %v after SIGTERM, want about %v", d, killAfter) | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // C1 + C9: a script that exits on SIGINT stops the escalation: the timers are | |||
| 193 | + | // stopped when Wait returns, so no SIGTERM/SIGKILL follows, and nothing is | |||
| 194 | + | // signalled after the shell was reaped except the final group sweep. | |||
| 195 | + | func TestExecEscalationStopsWhenScriptExits(t *testing.T) { | |||
| 196 | + | rec := &sigRecorder{start: time.Now()} | |||
| 197 | + | _, _, exit, _, _, _ := host(t, `trap 'exit 130' INT; while :; do sleep 0.05; done`, 200*time.Millisecond, func(c *ExecConfig) { | |||
| 198 | + | c.TermAfter, c.KillAfter, c.signal = 300*time.Millisecond, 300*time.Millisecond, rec.fn | |||
| 199 | + | }) | |||
| 200 | + | if exit.Code != 130 { | |||
| 201 | + | t.Fatalf("exit=%v", exit) | |||
| 202 | + | } | |||
| 203 | + | time.Sleep(1200 * time.Millisecond) // well past TermAfter+KillAfter | |||
| 204 | + | rec.mu.Lock() | |||
| 205 | + | defer rec.mu.Unlock() | |||
| 206 | + | if len(rec.got) != 2 || rec.got[0] != "interrupt" || rec.got[1] != "killed" { | |||
| 207 | + | t.Fatalf("want SIGINT then the final sweep only, got %v", rec.got) | |||
| 208 | + | } | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | // C9: after the shell was reaped the escalator never signals again, even if its | |||
| 212 | + | // timers had fired concurrently. | |||
| 213 | + | func TestEscalatorNeverSignalsAfterFinish(t *testing.T) { | |||
| 214 | + | var n atomic.Int32 | |||
| 215 | + | e := &escalator{cfg: ExecConfig{TermAfter: time.Millisecond, KillAfter: time.Millisecond, signal: func(int, syscall.Signal) { n.Add(1) }}, pgid: 1 << 30} | |||
| 216 | + | e.cancel() | |||
| 217 | + | e.finish() | |||
| 218 | + | after := n.Load() | |||
| 219 | + | time.Sleep(50 * time.Millisecond) | |||
| 220 | + | e.send(syscall.SIGTERM) | |||
| 221 | + | e.cancel() | |||
| 222 | + | if n.Load() != after { | |||
| 223 | + | t.Fatalf("signalled %d times after finish", n.Load()-after) | |||
| 224 | + | } | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | // C2: a background child that keeps stdout open must not hang the executor: it | |||
| 228 | + | // reports the script's exit promptly and the child is killed. | |||
| 229 | + | func TestExecBackgroundChildDoesNotHoldTheSession(t *testing.T) { | |||
| 230 | + | marker := filepath.Join(t.TempDir(), "child.pid") | |||
| 231 | + | start := time.Now() | |||
| 232 | + | out, _, exit, _, _, _ := host(t, `sleep 30 & echo $! > `+marker+`; echo parent-done`, 0, func(c *ExecConfig) { c.WaitDelay = 300 * time.Millisecond }) | |||
| 233 | + | if time.Since(start) > 5*time.Second { | |||
| 234 | + | t.Fatalf("the session was held by a background child for %v", time.Since(start)) | |||
| 235 | + | } | |||
| 236 | + | if exit == nil || exit.Code != 0 || !strings.Contains(out, "parent-done") { | |||
| 237 | + | t.Fatalf("exit=%v out=%q", exit, out) | |||
| 238 | + | } | |||
| 239 | + | b, err := os.ReadFile(marker) | |||
| 240 | + | if err != nil { | |||
| 241 | + | t.Fatal(err) | |||
| 242 | + | } | |||
| 243 | + | pid, _ := strconv.Atoi(strings.TrimSpace(string(b))) | |||
| 244 | + | deadline := time.Now().Add(3 * time.Second) | |||
| 245 | + | for time.Now().Before(deadline) { | |||
| 246 | + | if st, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)); err != nil || strings.Contains(string(st), ") Z") { | |||
| 247 | + | return | |||
| 248 | + | } | |||
| 249 | + | time.Sleep(50 * time.Millisecond) | |||
| 250 | + | } | |||
| 251 | + | t.Fatalf("background child %d survived the job", pid) | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | // C2: output written by the script before the shell exits is complete even when | |||
| 255 | + | // the stream is large and a background child holds the pipe. | |||
| 256 | + | func TestExecOutputCompleteWithBackgroundChild(t *testing.T) { | |||
| 257 | + | out, _, exit, _, _, _ := host(t, `sleep 30 & head -c 200000 /dev/zero | tr '\0' 'y'`, 0, func(c *ExecConfig) { c.WaitDelay = 300 * time.Millisecond }) | |||
| 258 | + | if exit.Code != 0 || len(out) != 200000 { | |||
| 259 | + | t.Fatalf("len=%d exit=%v", len(out), exit) | |||
| 260 | + | } | |||
| 261 | + | } | |||
| 262 | + | ||||
| 142 | func TestExecKilledBySignalReports128PlusSignal(t *testing.T) { | 263 | func TestExecKilledBySignalReports128PlusSignal(t *testing.T) { | ||
| 143 | - | _, _, exit, _, _, _ := host(t, `kill -TERM $$`, nil) | 264 | + | _, _, exit, _, _, _ := host(t, `kill -TERM $$`, 0, nil) |
| 144 | if exit.Code != 128+int32(syscall.SIGTERM) { | 265 | if exit.Code != 128+int32(syscall.SIGTERM) { | ||
| 145 | t.Fatalf("exit=%v", exit) | 266 | t.Fatalf("exit=%v", exit) | ||
| 146 | } | 267 | } | ||
| @@ -156,7 +277,7 @@ func TestExecMissingShell(t *testing.T) { | |||||
| 156 | go func() { | 277 | go func() { | ||
| 157 | c, _ := ln.Accept() | 278 | c, _ := ln.Accept() | ||
| 158 | defer func() { _ = c.Close() }() | 279 | defer func() { _ = c.Close() }() | ||
| 159 | - | _, exit, _ := execstream.Drive(context.Background(), c, &execpb.Run{Script: "true"}, nil) | 280 | + | _, exit, _ := execstream.Drive(c, &execpb.Run{Script: "true"}, nil, nil) |
| 160 | if exit == nil || exit.Code != 127 || exit.Error == "" { | 281 | if exit == nil || exit.Code != 127 || exit.Error == "" { | ||
| 161 | t.Errorf("want 127 with an error message, got %v", exit) | 282 | t.Errorf("want 127 with an error message, got %v", exit) | ||
| 162 | } | 283 | } | ||
| @@ -233,14 +354,13 @@ func TestInitInContainerSkipsVMSetup(t *testing.T) { | |||||
| 233 | return | 354 | return | ||
| 234 | } | 355 | } | ||
| 235 | defer func() { _ = c.Close() }() | 356 | defer func() { _ = c.Close() }() | ||
| 236 | - | _, _, _ = execstream.Drive(context.Background(), c, &execpb.Run{Script: "exit 0"}, nil) | 357 | + | _, _, _ = execstream.Drive(c, &execpb.Run{Script: "exit 0"}, nil, nil) |
| 237 | }() | 358 | }() | ||
| 238 | code := Init(context.Background(), InitConfig{ | 359 | code := Init(context.Background(), InitConfig{ | ||
| 239 | - | Cmdline: "ermite.sandbox=vm ermite.connect=unix:" + sock, | 360 | + | Cmdline: "ermite.sandbox=vm ermite.connect=unix:" + sock, |
| 240 | - | Getpid: func() int { return 4242 }, | 361 | + | Getpid: func() int { return 4242 }, |
| 241 | - | ExecArgs: []string{"/bin/sh", "-c", "exit 5"}, | 362 | + | ExecArgs: []string{"/bin/sh", "-c", "exit 5"}, |
| 242 | - | DockerTimeout: time.Millisecond, | 363 | + | SkipDocker: true, |
| 243 | - | SkipDocker: true, | |||
| 244 | }) | 364 | }) | ||
| 245 | // ExecArgs is a stand-in for the executor: its exit code is Init's. | 365 | // ExecArgs is a stand-in for the executor: its exit code is Init's. | ||
| 246 | if code != 5 { | 366 | if code != 5 { | ||
| @@ -259,3 +379,33 @@ func TestWriteResolvConfNeedsGateway(t *testing.T) { | |||||
| 259 | t.Fatal("empty gateway accepted") | 379 | t.Fatal("empty gateway accepted") | ||
| 260 | } | 380 | } | ||
| 261 | } | 381 | } | ||
| 382 | + | ||||
| 383 | + | // C11: init forwards SIGTERM to the executor and finishes with the executor's | |||
| 384 | + | // own exit code, instead of dying with its child still running. | |||
| 385 | + | func TestInitForwardsSIGTERMToTheExecutor(t *testing.T) { | |||
| 386 | + | sigs := make(chan os.Signal, 1) | |||
| 387 | + | done := make(chan int, 1) | |||
| 388 | + | marker := filepath.Join(t.TempDir(), "got-term") | |||
| 389 | + | go func() { | |||
| 390 | + | done <- Init(context.Background(), InitConfig{ | |||
| 391 | + | Cmdline: "ermite.sandbox=container ermite.connect=unix:/nonexistent", | |||
| 392 | + | Getpid: func() int { return 4242 }, | |||
| 393 | + | SkipDocker: true, | |||
| 394 | + | Signals: sigs, | |||
| 395 | + | ExecArgs: []string{"/bin/sh", "-c", `trap 'echo term > ` + marker + `; exit 9' TERM; while :; do sleep 0.05; done`}, | |||
| 396 | + | }) | |||
| 397 | + | }() | |||
| 398 | + | time.Sleep(500 * time.Millisecond) | |||
| 399 | + | sigs <- syscall.SIGTERM | |||
| 400 | + | select { | |||
| 401 | + | case code := <-done: | |||
| 402 | + | if code != 9 { | |||
| 403 | + | t.Fatalf("init must return the executor's exit code, got %d", code) | |||
| 404 | + | } | |||
| 405 | + | case <-time.After(10 * time.Second): | |||
| 406 | + | t.Fatal("init did not finish after SIGTERM") | |||
| 407 | + | } | |||
| 408 | + | if _, err := os.Stat(marker); err != nil { | |||
| 409 | + | t.Fatal("the executor never received SIGTERM") | |||
| 410 | + | } | |||
| 411 | + | } | |||
M internal/guest/init.go +29 -21
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "context" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "os" | |
| 9 | + | "os/signal" | |
| 9 | 10 | "strings" | |
| 10 | 11 | "syscall" | |
| 11 | 12 | "time" | |
| @@ -24,8 +25,9 @@ type InitConfig struct { | |||
| 24 | 25 | Cmdline string | |
| 25 | 26 | // Getpid reports the process id; Init only acts as a VM's PID 1 when it is 1. | |
| 26 | 27 | Getpid func() int | |
| 27 | - | // DockerTimeout bounds the wait for the Docker socket (default 60 s). | |
| 28 | - | DockerTimeout time.Duration | |
| 28 | + | // Signals delivers the termination signals to forward to the executor; | |
| 29 | + | // nil means "subscribe to SIGTERM and SIGINT". Tests inject their own. | |
| 30 | + | Signals <-chan os.Signal | |
| 29 | 31 | // SkipDocker does not start dockerd (tests, images without Docker). | |
| 30 | 32 | SkipDocker bool | |
| 31 | 33 | // ExecArgs is the executor command line; default | |
| @@ -51,9 +53,6 @@ func Init(ctx context.Context, cfg InitConfig) int { | |||
| 51 | 53 | if cfg.Getpid == nil { | |
| 52 | 54 | cfg.Getpid = os.Getpid | |
| 53 | 55 | } | |
| 54 | - | if cfg.DockerTimeout == 0 { | |
| 55 | - | cfg.DockerTimeout = 60 * time.Second | |
| 56 | - | } | |
| 57 | 56 | if cfg.ExecArgs == nil { | |
| 58 | 57 | cfg.ExecArgs = []string{sandbox.GuestExecDir + "/" + sandbox.GuestExecName, "exec"} | |
| 59 | 58 | } | |
| @@ -134,11 +133,32 @@ func runPayload(ctx context.Context, cfg InitConfig, mode string, args map[strin | |||
| 134 | 133 | log.Error().Err(err).Msg("init: start executor") | |
| 135 | 134 | return 1 | |
| 136 | 135 | } | |
| 137 | - | // Docker is awaited in the background: the executor starts right away and | |
| 138 | - | // the stub script's `docker info` waits for the socket itself. | |
| 139 | - | if dockerPid != 0 { | |
| 140 | - | go waitSocket(ctx, "/var/run/docker.sock", cfg.DockerTimeout) | |
| 136 | + | // SIGTERM/SIGINT (docker stop, or the host stopping the VM) go to the | |
| 137 | + | // executor, which cancels its script with the usual escalation and reports | |
| 138 | + | // its Exit; init then finishes normally instead of dying with children. | |
| 139 | + | sigs := cfg.Signals | |
| 140 | + | if sigs == nil { | |
| 141 | + | ch := make(chan os.Signal, 4) | |
| 142 | + | signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT) | |
| 143 | + | defer signal.Stop(ch) | |
| 144 | + | sigs = ch | |
| 141 | 145 | } | |
| 146 | + | stopFwd := make(chan struct{}) | |
| 147 | + | defer close(stopFwd) | |
| 148 | + | go func() { | |
| 149 | + | for { | |
| 150 | + | select { | |
| 151 | + | case sig := <-sigs: | |
| 152 | + | if s, ok := sig.(syscall.Signal); ok { | |
| 153 | + | log.Info().Str("signal", s.String()).Msg("init: forwarding the signal to the executor") | |
| 154 | + | _ = syscall.Kill(-execPid, s) // the executor leads its own process group | |
| 155 | + | _ = syscall.Kill(execPid, s) | |
| 156 | + | } | |
| 157 | + | case <-stopFwd: | |
| 158 | + | return | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | }() | |
| 142 | 162 | code := reapUntil(execPid) | |
| 143 | 163 | if dockerPid != 0 { | |
| 144 | 164 | _ = syscall.Kill(dockerPid, syscall.SIGTERM) | |
| @@ -154,18 +174,6 @@ func runPayload(ctx context.Context, cfg InitConfig, mode string, args map[strin | |||
| 154 | 174 | return code | |
| 155 | 175 | } | |
| 156 | 176 | ||
| 157 | - | func waitSocket(ctx context.Context, path string, timeout time.Duration) { | |
| 158 | - | log := zerowrap.FromCtx(ctx) | |
| 159 | - | deadline := time.Now().Add(timeout) | |
| 160 | - | for time.Now().Before(deadline) { | |
| 161 | - | if _, err := os.Stat(path); err == nil { | |
| 162 | - | return | |
| 163 | - | } | |
| 164 | - | time.Sleep(200 * time.Millisecond) | |
| 165 | - | } | |
| 166 | - | log.Warn().Str("socket", path).Msg("init: docker socket did not appear") | |
| 167 | - | } | |
| 168 | - | ||
| 169 | 177 | // spawn starts argv[0] as a child (own process group) without os/exec, so that | |
| 170 | 178 | // reapUntil is the only waiter and orphans can be reaped safely. | |
| 171 | 179 | func spawn(argv, env []string) (int, error) { | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "context" | 6 | "context" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "os" | 8 | "os" | ||
| 9 | + | "os/signal" | |||
| 9 | "strings" | 10 | "strings" | ||
| 10 | "syscall" | 11 | "syscall" | ||
| 11 | "time" | 12 | "time" | ||
| @@ -24,8 +25,9 @@ type InitConfig struct { | |||||
| 24 | Cmdline string | 25 | Cmdline string | ||
| 25 | // Getpid reports the process id; Init only acts as a VM's PID 1 when it is 1. | 26 | // Getpid reports the process id; Init only acts as a VM's PID 1 when it is 1. | ||
| 26 | Getpid func() int | 27 | Getpid func() int | ||
| 27 | - | // DockerTimeout bounds the wait for the Docker socket (default 60 s). | 28 | + | // Signals delivers the termination signals to forward to the executor; |
| 28 | - | DockerTimeout time.Duration | 29 | + | // nil means "subscribe to SIGTERM and SIGINT". Tests inject their own. |
| 30 | + | Signals <-chan os.Signal | |||
| 29 | // SkipDocker does not start dockerd (tests, images without Docker). | 31 | // SkipDocker does not start dockerd (tests, images without Docker). | ||
| 30 | SkipDocker bool | 32 | SkipDocker bool | ||
| 31 | // ExecArgs is the executor command line; default | 33 | // ExecArgs is the executor command line; default | ||
| @@ -51,9 +53,6 @@ func Init(ctx context.Context, cfg InitConfig) int { | |||||
| 51 | if cfg.Getpid == nil { | 53 | if cfg.Getpid == nil { | ||
| 52 | cfg.Getpid = os.Getpid | 54 | cfg.Getpid = os.Getpid | ||
| 53 | } | 55 | } | ||
| 54 | - | if cfg.DockerTimeout == 0 { | |||
| 55 | - | cfg.DockerTimeout = 60 * time.Second | |||
| 56 | - | } | |||
| 57 | if cfg.ExecArgs == nil { | 56 | if cfg.ExecArgs == nil { | ||
| 58 | cfg.ExecArgs = []string{sandbox.GuestExecDir + "/" + sandbox.GuestExecName, "exec"} | 57 | cfg.ExecArgs = []string{sandbox.GuestExecDir + "/" + sandbox.GuestExecName, "exec"} | ||
| 59 | } | 58 | } | ||
| @@ -134,11 +133,32 @@ func runPayload(ctx context.Context, cfg InitConfig, mode string, args map[strin | |||||
| 134 | log.Error().Err(err).Msg("init: start executor") | 133 | log.Error().Err(err).Msg("init: start executor") | ||
| 135 | return 1 | 134 | return 1 | ||
| 136 | } | 135 | } | ||
| 137 | - | // Docker is awaited in the background: the executor starts right away and | 136 | + | // SIGTERM/SIGINT (docker stop, or the host stopping the VM) go to the |
| 138 | - | // the stub script's `docker info` waits for the socket itself. | 137 | + | // executor, which cancels its script with the usual escalation and reports |
| 139 | - | if dockerPid != 0 { | 138 | + | // its Exit; init then finishes normally instead of dying with children. |
| 140 | - | go waitSocket(ctx, "/var/run/docker.sock", cfg.DockerTimeout) | 139 | + | sigs := cfg.Signals |
| 140 | + | if sigs == nil { | |||
| 141 | + | ch := make(chan os.Signal, 4) | |||
| 142 | + | signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT) | |||
| 143 | + | defer signal.Stop(ch) | |||
| 144 | + | sigs = ch | |||
| 141 | } | 145 | } | ||
| 146 | + | stopFwd := make(chan struct{}) | |||
| 147 | + | defer close(stopFwd) | |||
| 148 | + | go func() { | |||
| 149 | + | for { | |||
| 150 | + | select { | |||
| 151 | + | case sig := <-sigs: | |||
| 152 | + | if s, ok := sig.(syscall.Signal); ok { | |||
| 153 | + | log.Info().Str("signal", s.String()).Msg("init: forwarding the signal to the executor") | |||
| 154 | + | _ = syscall.Kill(-execPid, s) // the executor leads its own process group | |||
| 155 | + | _ = syscall.Kill(execPid, s) | |||
| 156 | + | } | |||
| 157 | + | case <-stopFwd: | |||
| 158 | + | return | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | }() | |||
| 142 | code := reapUntil(execPid) | 162 | code := reapUntil(execPid) | ||
| 143 | if dockerPid != 0 { | 163 | if dockerPid != 0 { | ||
| 144 | _ = syscall.Kill(dockerPid, syscall.SIGTERM) | 164 | _ = syscall.Kill(dockerPid, syscall.SIGTERM) | ||
| @@ -154,18 +174,6 @@ func runPayload(ctx context.Context, cfg InitConfig, mode string, args map[strin | |||||
| 154 | return code | 174 | return code | ||
| 155 | } | 175 | } | ||
| 156 | 176 | ||||
| 157 | - | func waitSocket(ctx context.Context, path string, timeout time.Duration) { | |||
| 158 | - | log := zerowrap.FromCtx(ctx) | |||
| 159 | - | deadline := time.Now().Add(timeout) | |||
| 160 | - | for time.Now().Before(deadline) { | |||
| 161 | - | if _, err := os.Stat(path); err == nil { | |||
| 162 | - | return | |||
| 163 | - | } | |||
| 164 | - | time.Sleep(200 * time.Millisecond) | |||
| 165 | - | } | |||
| 166 | - | log.Warn().Str("socket", path).Msg("init: docker socket did not appear") | |||
| 167 | - | } | |||
| 168 | - | ||||
| 169 | // spawn starts argv[0] as a child (own process group) without os/exec, so that | 177 | // spawn starts argv[0] as a child (own process group) without os/exec, so that | ||
| 170 | // reapUntil is the only waiter and orphans can be reaped safely. | 178 | // reapUntil is the only waiter and orphans can be reaped safely. | ||
| 171 | func spawn(argv, env []string) (int, error) { | 179 | func spawn(argv, env []string) (int, error) { | ||
M internal/sandbox/container/container_test.go +1 -1
| @@ -242,7 +242,7 @@ func (e *cenv) leftovers() []string { | |||
| 242 | 242 | func stub(t *testing.T, conn sandbox.Conn) (string, int) { | |
| 243 | 243 | t.Helper() | |
| 244 | 244 | var out strings.Builder | |
| 245 | - | _, exit, err := execstream.Drive(context.Background(), conn, &execpb.Run{}, func(o *execpb.Output) { out.Write(o.Data) }) | |
| 245 | + | _, exit, err := execstream.Drive(conn, &execpb.Run{}, nil, func(o *execpb.Output) { out.Write(o.Data) }) | |
| 246 | 246 | if err != nil { | |
| 247 | 247 | t.Fatal(err) | |
| 248 | 248 | } | |
| @@ -242,7 +242,7 @@ func (e *cenv) leftovers() []string { | |||||
| 242 | func stub(t *testing.T, conn sandbox.Conn) (string, int) { | 242 | func stub(t *testing.T, conn sandbox.Conn) (string, int) { | ||
| 243 | t.Helper() | 243 | t.Helper() | ||
| 244 | var out strings.Builder | 244 | var out strings.Builder | ||
| 245 | - | _, exit, err := execstream.Drive(context.Background(), conn, &execpb.Run{}, func(o *execpb.Output) { out.Write(o.Data) }) | 245 | + | _, exit, err := execstream.Drive(conn, &execpb.Run{}, nil, func(o *execpb.Output) { out.Write(o.Data) }) |
| 246 | if err != nil { | 246 | if err != nil { | ||
| 247 | t.Fatal(err) | 247 | t.Fatal(err) | ||
| 248 | } | 248 | } | ||
M internal/sandbox/container/docker_api_test.go +1 -1
| @@ -73,7 +73,7 @@ func TestDockerAPIWiring(t *testing.T) { | |||
| 73 | 73 | t.Fatalf("job %d: %v", i, err) | |
| 74 | 74 | } | |
| 75 | 75 | var out strings.Builder | |
| 76 | - | hello, exit, err := execstream.Drive(ctx, conn, &execpb.Run{Script: "echo wired; id -u; exit 4"}, func(o *execpb.Output) { out.Write(o.Data) }) | |
| 76 | + | hello, exit, err := execstream.Drive(conn, &execpb.Run{Script: "echo wired; id -u; exit 4"}, nil, func(o *execpb.Output) { out.Write(o.Data) }) | |
| 77 | 77 | if err != nil { | |
| 78 | 78 | t.Fatal(err) | |
| 79 | 79 | } | |
| @@ -73,7 +73,7 @@ func TestDockerAPIWiring(t *testing.T) { | |||||
| 73 | t.Fatalf("job %d: %v", i, err) | 73 | t.Fatalf("job %d: %v", i, err) | ||
| 74 | } | 74 | } | ||
| 75 | var out strings.Builder | 75 | var out strings.Builder | ||
| 76 | - | hello, exit, err := execstream.Drive(ctx, conn, &execpb.Run{Script: "echo wired; id -u; exit 4"}, func(o *execpb.Output) { out.Write(o.Data) }) | 76 | + | hello, exit, err := execstream.Drive(conn, &execpb.Run{Script: "echo wired; id -u; exit 4"}, nil, func(o *execpb.Output) { out.Write(o.Data) }) |
| 77 | if err != nil { | 77 | if err != nil { | ||
| 78 | t.Fatal(err) | 78 | t.Fatal(err) | ||
| 79 | } | 79 | } | ||
M internal/sandbox/integration/integration_test.go +1 -1
| @@ -125,7 +125,7 @@ type result struct { | |||
| 125 | 125 | ||
| 126 | 126 | func runJob(ctx context.Context, conn sandbox.Conn, script string) (result, error) { | |
| 127 | 127 | var so, se strings.Builder | |
| 128 | - | hello, exit, err := execstream.Drive(ctx, conn, &execpb.Run{Script: script}, func(o *execpb.Output) { | |
| 128 | + | hello, exit, err := execstream.Drive(conn, &execpb.Run{Script: script}, nil, func(o *execpb.Output) { | |
| 129 | 129 | if o.Stream == execpb.StreamKind_STREAM_KIND_STDERR { | |
| 130 | 130 | se.Write(o.Data) | |
| 131 | 131 | } else { | |
| @@ -125,7 +125,7 @@ type result struct { | |||||
| 125 | 125 | ||||
| 126 | func runJob(ctx context.Context, conn sandbox.Conn, script string) (result, error) { | 126 | func runJob(ctx context.Context, conn sandbox.Conn, script string) (result, error) { | ||
| 127 | var so, se strings.Builder | 127 | var so, se strings.Builder | ||
| 128 | - | hello, exit, err := execstream.Drive(ctx, conn, &execpb.Run{Script: script}, func(o *execpb.Output) { | 128 | + | hello, exit, err := execstream.Drive(conn, &execpb.Run{Script: script}, nil, func(o *execpb.Output) { |
| 129 | if o.Stream == execpb.StreamKind_STREAM_KIND_STDERR { | 129 | if o.Stream == execpb.StreamKind_STREAM_KIND_STDERR { | ||
| 130 | se.Write(o.Data) | 130 | se.Write(o.Data) | ||
| 131 | } else { | 131 | } else { | ||
M internal/sandbox/vm/vm_test.go +1 -1
| @@ -196,7 +196,7 @@ func (e *env) leftovers(t *testing.T) []string { | |||
| 196 | 196 | func runStub(t *testing.T, conn sandbox.Conn) (string, int) { | |
| 197 | 197 | t.Helper() | |
| 198 | 198 | var out strings.Builder | |
| 199 | - | _, exit, err := execstream.Drive(context.Background(), conn, &execpb.Run{}, func(o *execpb.Output) { out.Write(o.Data) }) | |
| 199 | + | _, exit, err := execstream.Drive(conn, &execpb.Run{}, nil, func(o *execpb.Output) { out.Write(o.Data) }) | |
| 200 | 200 | if err != nil { | |
| 201 | 201 | t.Fatal(err) | |
| 202 | 202 | } | |
| @@ -196,7 +196,7 @@ func (e *env) leftovers(t *testing.T) []string { | |||||
| 196 | func runStub(t *testing.T, conn sandbox.Conn) (string, int) { | 196 | func runStub(t *testing.T, conn sandbox.Conn) (string, int) { | ||
| 197 | t.Helper() | 197 | t.Helper() | ||
| 198 | var out strings.Builder | 198 | var out strings.Builder | ||
| 199 | - | _, exit, err := execstream.Drive(context.Background(), conn, &execpb.Run{}, func(o *execpb.Output) { out.Write(o.Data) }) | 199 | + | _, exit, err := execstream.Drive(conn, &execpb.Run{}, nil, func(o *execpb.Output) { out.Write(o.Data) }) |
| 200 | if err != nil { | 200 | if err != nil { | ||
| 201 | t.Fatal(err) | 201 | t.Fatal(err) | ||
| 202 | } | 202 | } | ||
M sandbox_check.go +5 -1
| @@ -55,7 +55,11 @@ func sandboxCheck(ctx context.Context, args []string) int { | |||
| 55 | 55 | fmt.Fprintln(os.Stderr, "ermite-runner:", err) | |
| 56 | 56 | return 1 | |
| 57 | 57 | } | |
| 58 | - | _, exit, err := execstream.Drive(ctx, conn, &execpb.Run{Script: strings.Join(args, " ")}, func(o *execpb.Output) { | |
| 58 | + | // Ctrl-C / SIGTERM cancel the job: the executor escalates by itself. | |
| 59 | + | cancel := make(chan *execpb.Cancel, 1) | |
| 60 | + | stop := context.AfterFunc(ctx, func() { cancel <- &execpb.Cancel{Reason: "interrupted"} }) | |
| 61 | + | defer stop() | |
| 62 | + | _, exit, err := execstream.Drive(conn, &execpb.Run{Script: strings.Join(args, " ")}, cancel, func(o *execpb.Output) { | |
| 59 | 63 | f := os.Stdout | |
| 60 | 64 | if o.Stream == execpb.StreamKind_STREAM_KIND_STDERR { | |
| 61 | 65 | f = os.Stderr | |
| @@ -55,7 +55,11 @@ func sandboxCheck(ctx context.Context, args []string) int { | |||||
| 55 | fmt.Fprintln(os.Stderr, "ermite-runner:", err) | 55 | fmt.Fprintln(os.Stderr, "ermite-runner:", err) | ||
| 56 | return 1 | 56 | return 1 | ||
| 57 | } | 57 | } | ||
| 58 | - | _, exit, err := execstream.Drive(ctx, conn, &execpb.Run{Script: strings.Join(args, " ")}, func(o *execpb.Output) { | 58 | + | // Ctrl-C / SIGTERM cancel the job: the executor escalates by itself. |
| 59 | + | cancel := make(chan *execpb.Cancel, 1) | |||
| 60 | + | stop := context.AfterFunc(ctx, func() { cancel <- &execpb.Cancel{Reason: "interrupted"} }) | |||
| 61 | + | defer stop() | |||
| 62 | + | _, exit, err := execstream.Drive(conn, &execpb.Run{Script: strings.Join(args, " ")}, cancel, func(o *execpb.Output) { | |||
| 59 | f := os.Stdout | 63 | f := os.Stdout | ||
| 60 | if o.Stream == execpb.StreamKind_STREAM_KIND_STDERR { | 64 | if o.Stream == execpb.StreamKind_STREAM_KIND_STDERR { | ||
| 61 | f = os.Stderr | 65 | f = os.Stderr | ||