Compare commits

..

28 Commits

Author SHA1 Message Date
Jeff Martin b06c585762 feat: propagate actions dependencies (#4372)
Code Scanning - Action / CodeQL-Build (push) Has been cancelled
Runner CI / build (./dev, windows-latest, win-arm64) (push) Has been cancelled
Runner CI / build (./dev, windows-latest, win-x64) (push) Has been cancelled
Runner CI / build (./dev.sh, macOS-latest, osx-arm64) (push) Has been cancelled
Runner CI / build (./dev.sh, macOS-latest, osx-x64) (push) Has been cancelled
Runner CI / build (./dev.sh, ubuntu-latest, linux-arm) (push) Has been cancelled
Runner CI / build (./dev.sh, ubuntu-latest, linux-arm64) (push) Has been cancelled
Runner CI / build (./dev.sh, ubuntu-latest, linux-x64) (push) Has been cancelled
Runner CI / docker (linux/amd64, ubuntu-latest) (push) Has been cancelled
Runner CI / docker (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
2026-04-23 20:32:07 +00:00
dependabot[bot] c6f978fd5f Bump @actions/glob from 0.6.1 to 0.7.0 in /src/Misc/expressionFunc/hashFiles (#4367)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-04-23 09:55:48 +01:00
dependabot[bot] d1690af497 Bump System.ServiceProcess.ServiceController from 10.0.6 to 10.0.7 (#4370)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 16:09:28 +01:00
Salman Chishti c87d955bad Prepping runner release 2.334.0 (#4365) 2026-04-21 19:40:21 +01:00
dependabot[bot] 7407189cf5 Bump Microsoft.DevTunnels.Connections from 1.3.16 to 1.3.39 (#4339)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-04-21 17:49:08 +01:00
dependabot[bot] a84fb3602d Bump typescript from 6.0.2 to 6.0.3 in /src/Misc/expressionFunc/hashFiles (#4353)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-04-21 17:39:38 +01:00
dependabot[bot] 84598e03fa Bump System.ServiceProcess.ServiceController from 10.0.3 to 10.0.6 (#4358)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-04-21 17:38:26 +01:00
dependabot[bot] 8fa7457bbf Bump @typescript-eslint/eslint-plugin from 8.58.1 to 8.59.0 in /src/Misc/expressionFunc/hashFiles (#4359)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-04-21 16:36:21 +00:00
Salman Chishti 00af8379a2 Add vulnerability-alerts permission (#4350) 2026-04-21 17:31:10 +01:00
dependabot[bot] 6692e6a590 Bump System.Formats.Asn1 and System.Security.Cryptography.Pkcs (#4362)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-04-21 16:06:40 +00:00
dependabot[bot] cacb25d2ed Bump @typescript-eslint/parser from 8.58.1 to 8.59.0 in /src/Misc/expressionFunc/hashFiles (#4360)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-21 15:15:02 +01:00
github-actions[bot] c6ca9f6edb Update dotnet sdk to latest version @8.0.420 (#4356)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-20 17:06:35 +00:00
Copilot fad1253513 Bump Docker version to 29.4.0 (#4352)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: luketomlinson <19689611+luketomlinson@users.noreply.github.com>
2026-04-20 10:45:12 -04:00
github-actions[bot] 45debbd528 chore: update Node versions (#4355)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-20 12:44:20 +00:00
Francesco Renzi 43e5211996 Add WS bridge over DAP TCP server (#4328)
Co-authored-by: Tingluo Huang <tingluohuang@github.com>
2026-04-17 09:18:01 -04:00
Salman Chishti 4a587ada27 feat: add job.workflow_* typed accessors to JobContext (#4335) 2026-04-10 19:39:33 +01:00
Copilot 182a433782 Bump System.Formats.Asn1, Cryptography.Pkcs, ProtectedData, ServiceController, CodePages, Threading.Channels, @actions/glob, @typescript-eslint/parser, lint-staged, picomatch (#4333)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-04-10 12:40:28 +01:00
Salman Chishti 8d35e710da fix: only show changed versions in node upgrade PR description (#4332) 2026-04-10 11:34:08 +01:00
dependabot[bot] 2bcc65e864 Bump typescript from 5.9.3 to 6.0.2 in /src/Misc/expressionFunc/hashFiles (#4329)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-04-10 09:01:34 +01:00
dependabot[bot] 1ba5fdfd88 Bump actions/github-script from 8 to 9 (#4331)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 08:54:56 +01:00
dependabot[bot] 580116c18b Bump @typescript-eslint/eslint-plugin from 8.57.2 to 8.58.1 in /src/Misc/expressionFunc/hashFiles (#4327)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-04-08 07:44:38 +00:00
github-actions[bot] c9a1751d87 Update Docker to v29.3.1 and Buildx to v0.33.0 (#4324)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-04-08 08:40:32 +01:00
Francesco Renzi 7711dc53e2 Add devtunnel connection for debugger jobs (#4317) 2026-04-07 12:51:33 +00:00
dependabot[bot] df507886cb Bump brace-expansion in /src/Misc/expressionFunc/hashFiles (#4318)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-04-01 13:19:42 +01:00
Tingluo Huang 5c6dd47e76 Add support for Bearer token in action archive downloads (#4321) 2026-03-31 17:51:01 -04:00
Stefan Penner 7ff994b932 Batch and deduplicate action resolution across composite depths (#4296)
Co-authored-by: Stefan Penner <spenner@linkedin.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 13:28:43 -04:00
github-actions[bot] b9275b59cf chore: update Node versions (#4319)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-30 12:58:17 +00:00
eric sciple f0c228635e Remove AllowCaseFunction feature flag (#4316) 2026-03-27 11:45:42 -05:00
57 changed files with 3999 additions and 1039 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
"ghcr.io/devcontainers/features/dotnet": {
"version": "8.0.419"
"version": "8.0.420"
},
"ghcr.io/devcontainers/features/node:1": {
"version": "20"
+1 -1
View File
@@ -99,7 +99,7 @@ jobs:
- name: Get latest runner version
id: latest_runner
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
- name: Compute image version
id: image
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const fs = require('fs');
+21 -3
View File
@@ -159,18 +159,36 @@ jobs:
git config --global user.name "github-actions[bot]"
git config --global user.email "<41898282+github-actions[bot]@users.noreply.github.com>"
# Build version summary for commit message and PR body (only include changed versions)
COMMIT_VERSIONS=""
PR_VERSION_LINES=""
if [ "${{ steps.node-versions.outputs.needs_update20 }}" == "true" ]; then
COMMIT_VERSIONS="20: $NODE20_VERSION"
PR_VERSION_LINES="- Node 20: ${{ steps.node-versions.outputs.current_node20 }} → $NODE20_VERSION"
fi
if [ "${{ steps.node-versions.outputs.needs_update24 }}" == "true" ]; then
if [ -n "$COMMIT_VERSIONS" ]; then
COMMIT_VERSIONS="$COMMIT_VERSIONS, 24: $NODE24_VERSION"
else
COMMIT_VERSIONS="24: $NODE24_VERSION"
fi
PR_VERSION_LINES="${PR_VERSION_LINES:+$PR_VERSION_LINES
}- Node 24: ${{ steps.node-versions.outputs.current_node24 }} → $NODE24_VERSION"
fi
# Create branch and commit changes
branch_name="chore/update-node"
git checkout -b "$branch_name"
git commit -a -m "chore: update Node versions (20: $NODE20_VERSION, 24: $NODE24_VERSION)"
git commit -a -m "chore: update Node versions ($COMMIT_VERSIONS)"
git push --force origin "$branch_name"
# Create PR body using here-doc for proper formatting
cat > pr_body.txt << EOF
Automated Node.js version update:
- Node 20: ${{ steps.node-versions.outputs.current_node20 }} → $NODE20_VERSION
- Node 24: ${{ steps.node-versions.outputs.current_node24 }} → $NODE24_VERSION
$PR_VERSION_LINES
This update ensures we're using the latest stable Node.js versions for security and performance improvements.
+3 -3
View File
@@ -16,7 +16,7 @@ jobs:
# Make sure ./releaseVersion match ./src/runnerversion
# Query GitHub release ensure version is not used
- name: Check version
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
@@ -171,7 +171,7 @@ jobs:
# Create ReleaseNote file
- name: Create ReleaseNote
id: releaseNote
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
@@ -300,7 +300,7 @@ jobs:
- name: Compute image version
id: image
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const fs = require('fs');
+2 -2
View File
@@ -5,8 +5,8 @@ ARG TARGETOS
ARG TARGETARCH
ARG RUNNER_VERSION
ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0
ARG DOCKER_VERSION=29.3.0
ARG BUILDX_VERSION=0.32.1
ARG DOCKER_VERSION=29.4.0
ARG BUILDX_VERSION=0.33.0
RUN apt update -y && apt install curl unzip -y
+29 -26
View File
@@ -1,33 +1,36 @@
## What's Changed
* Log inner exception message. by @TingluoHuang in https://github.com/actions/runner/pull/4265
* Fix composite post-step marker display names by @ericsciple in https://github.com/actions/runner/pull/4267
* Bump actions/download-artifact from 7 to 8 by @dependabot[bot] in https://github.com/actions/runner/pull/4269
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4272
* Avoid throw in SelfUpdaters. by @TingluoHuang in https://github.com/actions/runner/pull/4274
* Fix parser comparison mismatches by @ericsciple in https://github.com/actions/runner/pull/4273
* Devcontainer: bump base image Ubuntu version by @MaxHorstmann in https://github.com/actions/runner/pull/4277
* Support `entrypoint` and `command` for service containers by @ericsciple in https://github.com/actions/runner/pull/4276
* Bump actions/upload-artifact from 6 to 7 by @dependabot[bot] in https://github.com/actions/runner/pull/4270
* Bump docker/login-action from 3 to 4 by @dependabot[bot] in https://github.com/actions/runner/pull/4278
* Fix positional arg bug in ExpressionParser.CreateTree by @ericsciple in https://github.com/actions/runner/pull/4279
* Bump docker/build-push-action from 6 to 7 by @dependabot[bot] in https://github.com/actions/runner/pull/4283
* Bump docker/setup-buildx-action from 3 to 4 by @dependabot[bot] in https://github.com/actions/runner/pull/4282
* Bump actions/attest-build-provenance from 3 to 4 by @dependabot[bot] in https://github.com/actions/runner/pull/4266
* Bump @stylistic/eslint-plugin from 5.9.0 to 5.10.0 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4281
* Update Docker to v29.3.0 and Buildx to v0.32.1 by @github-actions[bot] in https://github.com/actions/runner/pull/4286
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4287
* Fix cancellation token race during parser comparison by @ericsciple in https://github.com/actions/runner/pull/4280
* Bump @typescript-eslint/eslint-plugin from 8.47.0 to 8.54.0 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4230
* Exit with specified exit code when runner is outdated by @nikola-jokic in https://github.com/actions/runner/pull/4285
* Report infra_error for action download failures. by @TingluoHuang in https://github.com/actions/runner/pull/4294
* Update dotnet sdk to latest version @8.0.419 by @github-actions[bot] in https://github.com/actions/runner/pull/4301
* Node 24 enforcement + Linux ARM32 deprecation support by @salmanmkc in https://github.com/actions/runner/pull/4303
* Bump @typescript-eslint/eslint-plugin from 8.54.0 to 8.57.1 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4304
* Bump flatted from 3.2.7 to 3.4.2 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4307
* Add DAP server by @rentziass in https://github.com/actions/runner/pull/4298
* Bump @typescript-eslint/eslint-plugin from 8.57.1 to 8.57.2 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4310
* Remove AllowCaseFunction feature flag by @ericsciple in https://github.com/actions/runner/pull/4316
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4319
* Batch and deduplicate action resolution across composite depths by @stefanpenner in https://github.com/actions/runner/pull/4296
* Add support for Bearer token in action archive downloads by @TingluoHuang in https://github.com/actions/runner/pull/4321
* Bump brace-expansion in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4318
* Add devtunnel connection for debugger jobs by @rentziass in https://github.com/actions/runner/pull/4317
* Update Docker to v29.3.1 and Buildx to v0.33.0 by @github-actions[bot] in https://github.com/actions/runner/pull/4324
* Bump @typescript-eslint/eslint-plugin from 8.57.2 to 8.58.1 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4327
* Bump actions/github-script from 8 to 9 by @dependabot[bot] in https://github.com/actions/runner/pull/4331
* Bump typescript from 5.9.3 to 6.0.2 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4329
* fix: only show changed versions in node upgrade PR description by @salmanmkc in https://github.com/actions/runner/pull/4332
* Bump System.Formats.Asn1, Cryptography.Pkcs, ProtectedData, ServiceController, CodePages, Threading.Channels, @actions/glob, @typescript-eslint/parser, lint-staged, picomatch by @Copilot in https://github.com/actions/runner/pull/4333
* feat: add `job.workflow_*` typed accessors to JobContext by @salmanmkc in https://github.com/actions/runner/pull/4335
* Add WS bridge over DAP TCP server by @rentziass in https://github.com/actions/runner/pull/4328
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4355
* Bump Docker version to 29.4.0 by @Copilot in https://github.com/actions/runner/pull/4352
* Update dotnet sdk to latest version @8.0.420 by @github-actions[bot] in https://github.com/actions/runner/pull/4356
* Bump @typescript-eslint/parser from 8.58.1 to 8.59.0 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4360
* Bump System.Formats.Asn1 and System.Security.Cryptography.Pkcs by @dependabot[bot] in https://github.com/actions/runner/pull/4362
* Add vulnerability-alerts permission by @salmanmkc in https://github.com/actions/runner/pull/4350
* Bump @typescript-eslint/eslint-plugin from 8.58.1 to 8.59.0 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4359
* Bump System.ServiceProcess.ServiceController from 10.0.3 to 10.0.6 by @dependabot[bot] in https://github.com/actions/runner/pull/4358
* Bump typescript from 6.0.2 to 6.0.3 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4353
* Bump Microsoft.DevTunnels.Connections from 1.3.16 to 1.3.39 by @dependabot[bot] in https://github.com/actions/runner/pull/4339
## New Contributors
* @MaxHorstmann made their first contribution in https://github.com/actions/runner/pull/4277
* @stefanpenner made their first contribution in https://github.com/actions/runner/pull/4296
**Full Changelog**: https://github.com/actions/runner/compare/v2.332.0...v2.333.0
**Full Changelog**: https://github.com/actions/runner/compare/v2.333.1...v2.334.0
_Note: Actions Runner follows a progressive release policy, so the latest release might not be available to your enterprise, organization, or repository yet.
To confirm which version of the Actions Runner you should expect, please view the download instructions for your enterprise, organization, or repository.
File diff suppressed because it is too large Load Diff
@@ -32,20 +32,20 @@
"author": "GitHub Actions",
"license": "MIT",
"dependencies": {
"@actions/glob": "^0.4.0"
"@actions/glob": "^0.7.0"
},
"devDependencies": {
"@stylistic/eslint-plugin": "^5.10.0",
"@types/node": "^22.0.0",
"@typescript-eslint/eslint-plugin": "^8.57.2",
"@typescript-eslint/parser": "^8.0.0",
"@typescript-eslint/eslint-plugin": "^8.59.0",
"@typescript-eslint/parser": "^8.59.0",
"@vercel/ncc": "^0.38.3",
"eslint": "^8.47.0",
"eslint-plugin-github": "^4.10.2",
"eslint-plugin-prettier": "^5.0.0",
"husky": "^9.1.7",
"lint-staged": "^15.5.0",
"lint-staged": "^16.4.0",
"prettier": "^3.0.3",
"typescript": "^5.9.3"
"typescript": "^6.0.3"
}
}
+2 -2
View File
@@ -6,8 +6,8 @@ NODE_URL=https://nodejs.org/dist
NODE_ALPINE_URL=https://github.com/actions/alpine_nodejs/releases/download
# When you update Node versions you must also create a new release of alpine_nodejs at that updated version.
# Follow the instructions here: https://github.com/actions/alpine_nodejs?tab=readme-ov-file#getting-started
NODE20_VERSION="20.20.1"
NODE24_VERSION="24.14.0"
NODE20_VERSION="20.20.2"
NODE24_VERSION="24.15.0"
get_abs_path() {
# exploits the fact that pwd will print abs path when no args
+2
View File
@@ -177,6 +177,8 @@ namespace GitHub.Runner.Common
public static readonly string SetOrchestrationIdEnvForActions = "actions_set_orchestration_id_env_for_actions";
public static readonly string SendJobLevelAnnotations = "actions_send_job_level_annotations";
public static readonly string EmitCompositeMarkers = "actions_runner_emit_composite_markers";
public static readonly string BatchActionResolution = "actions_batch_action_resolution";
public static readonly string UseBearerTokenForCodeload = "actions_use_bearer_token_for_codeload";
}
// Node version migration related constants
+3 -3
View File
@@ -17,9 +17,9 @@
<ItemGroup>
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="8.0.0" />
<PackageReference Include="System.Threading.Channels" Version="8.0.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.3" />
<PackageReference Include="System.Threading.Channels" Version="10.0.3" />
</ItemGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
+7
View File
@@ -12,6 +12,13 @@ namespace GitHub.Runner.Common
private ISecretMasker _secretMasker;
private TraceSource _traceSource;
/// <summary>
/// The underlying <see cref="System.Diagnostics.TraceSource"/> for this instance.
/// Useful when third-party libraries require a <see cref="System.Diagnostics.TraceSource"/>
/// to route their diagnostics into the runner's log infrastructure.
/// </summary>
public TraceSource Source => _traceSource;
public Tracing(string name, ISecretMasker secretMasker, SourceSwitch sourceSwitch, HostTraceListener traceListener, StdoutTraceListener stdoutTraceListener = null)
{
ArgUtil.NotNull(secretMasker, nameof(secretMasker));
+2 -2
View File
@@ -22,8 +22,8 @@
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="System.IO.FileSystem.AccessControl" Version="5.0.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.1" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.7" />
</ItemGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
+2 -2
View File
@@ -15,9 +15,9 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.Text.Encoding.CodePages" Version="8.0.0" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.3" />
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="System.Threading.Channels" Version="8.0.0" />
<PackageReference Include="System.Threading.Channels" Version="10.0.3" />
</ItemGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
+248 -9
View File
@@ -79,6 +79,13 @@ namespace GitHub.Runner.Worker
PreStepTracker = new Dictionary<Guid, IActionRunner>()
};
var containerSetupSteps = new List<JobExtensionRunner>();
var batchActionResolution = (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.BatchActionResolution) ?? false)
|| StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION"));
// Stack-local cache: same action (owner/repo@ref) is resolved only once,
// even if it appears at multiple depths in a composite tree.
var resolvedDownloadInfos = batchActionResolution
? new Dictionary<string, WebApi.ActionDownloadInfo>(StringComparer.Ordinal)
: null;
var depth = 0;
// We are running at the start of a job
if (rootStepId == default(Guid))
@@ -105,7 +112,9 @@ namespace GitHub.Runner.Worker
PrepareActionsState result = new PrepareActionsState();
try
{
result = await PrepareActionsRecursiveAsync(executionContext, state, actions, depth, rootStepId);
result = batchActionResolution
? await PrepareActionsRecursiveAsync(executionContext, state, actions, resolvedDownloadInfos, depth, rootStepId)
: await PrepareActionsRecursiveLegacyAsync(executionContext, state, actions, depth, rootStepId);
}
catch (FailedToResolveActionDownloadInfoException ex)
{
@@ -169,7 +178,192 @@ namespace GitHub.Runner.Worker
return new PrepareResult(containerSetupSteps, result.PreStepTracker);
}
private async Task<PrepareActionsState> PrepareActionsRecursiveAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable<Pipelines.ActionStep> actions, Int32 depth = 0, Guid parentStepId = default(Guid))
private async Task<PrepareActionsState> PrepareActionsRecursiveAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable<Pipelines.ActionStep> actions, Dictionary<string, WebApi.ActionDownloadInfo> resolvedDownloadInfos, Int32 depth = 0, Guid parentStepId = default(Guid))
{
ArgUtil.NotNull(executionContext, nameof(executionContext));
if (depth > Constants.CompositeActionsMaxDepth)
{
throw new Exception($"Composite action depth exceeded max depth {Constants.CompositeActionsMaxDepth}");
}
var repositoryActions = new List<Pipelines.ActionStep>();
foreach (var action in actions)
{
if (action.Reference.Type == Pipelines.ActionSourceType.ContainerRegistry)
{
ArgUtil.NotNull(action, nameof(action));
var containerReference = action.Reference as Pipelines.ContainerRegistryReference;
ArgUtil.NotNull(containerReference, nameof(containerReference));
ArgUtil.NotNullOrEmpty(containerReference.Image, nameof(containerReference.Image));
if (!state.ImagesToPull.ContainsKey(containerReference.Image))
{
state.ImagesToPull[containerReference.Image] = new List<Guid>();
}
Trace.Info($"Action {action.Name} ({action.Id}) needs to pull image '{containerReference.Image}'");
state.ImagesToPull[containerReference.Image].Add(action.Id);
}
else if (action.Reference.Type == Pipelines.ActionSourceType.Repository)
{
repositoryActions.Add(action);
}
}
if (repositoryActions.Count > 0)
{
// Resolve download info, skipping any actions already cached.
await ResolveNewActionsAsync(executionContext, repositoryActions, resolvedDownloadInfos);
// Download each action.
foreach (var action in repositoryActions)
{
var lookupKey = GetDownloadInfoLookupKey(action);
if (string.IsNullOrEmpty(lookupKey))
{
continue;
}
if (!resolvedDownloadInfos.TryGetValue(lookupKey, out var downloadInfo))
{
throw new Exception($"Missing download info for {lookupKey}");
}
await DownloadRepositoryActionAsync(executionContext, downloadInfo);
}
// Parse action.yml and collect composite sub-actions for batched
// resolution below. Pre/post step registration is deferred until
// after recursion so that HasPre/HasPost reflect the full subtree.
var nextLevel = new List<(Pipelines.ActionStep action, Guid parentId)>();
foreach (var action in repositoryActions)
{
var setupInfo = PrepareRepositoryActionAsync(executionContext, action);
if (setupInfo != null && setupInfo.Container != null)
{
if (!string.IsNullOrEmpty(setupInfo.Container.Image))
{
if (!state.ImagesToPull.ContainsKey(setupInfo.Container.Image))
{
state.ImagesToPull[setupInfo.Container.Image] = new List<Guid>();
}
Trace.Info($"Action {action.Name} ({action.Id}) from repository '{setupInfo.Container.ActionRepository}' needs to pull image '{setupInfo.Container.Image}'");
state.ImagesToPull[setupInfo.Container.Image].Add(action.Id);
}
else
{
ArgUtil.NotNullOrEmpty(setupInfo.Container.ActionRepository, nameof(setupInfo.Container.ActionRepository));
if (!state.ImagesToBuild.ContainsKey(setupInfo.Container.ActionRepository))
{
state.ImagesToBuild[setupInfo.Container.ActionRepository] = new List<Guid>();
}
Trace.Info($"Action {action.Name} ({action.Id}) from repository '{setupInfo.Container.ActionRepository}' needs to build image '{setupInfo.Container.Dockerfile}'");
state.ImagesToBuild[setupInfo.Container.ActionRepository].Add(action.Id);
state.ImagesToBuildInfo[setupInfo.Container.ActionRepository] = setupInfo.Container;
}
}
else if (setupInfo != null && setupInfo.Steps != null && setupInfo.Steps.Count > 0)
{
foreach (var step in setupInfo.Steps)
{
nextLevel.Add((step, action.Id));
}
}
}
// Resolve all next-level sub-actions in one batch API call,
// then recurse per parent (which hits the cache, not the API).
if (nextLevel.Count > 0)
{
var nextLevelRepoActions = nextLevel
.Where(x => x.action.Reference.Type == Pipelines.ActionSourceType.Repository)
.Select(x => x.action)
.ToList();
await ResolveNewActionsAsync(executionContext, nextLevelRepoActions, resolvedDownloadInfos);
foreach (var group in nextLevel.GroupBy(x => x.parentId))
{
var groupActions = group.Select(x => x.action).ToList();
state = await PrepareActionsRecursiveAsync(executionContext, state, groupActions, resolvedDownloadInfos, depth + 1, group.Key);
}
}
// Register pre/post steps after recursion so that HasPre/HasPost
// are correct (they depend on _cachedEmbeddedPreSteps/PostSteps
// being populated by the recursive calls above).
foreach (var action in repositoryActions)
{
var repoAction = action.Reference as Pipelines.RepositoryPathReference;
if (repoAction.RepositoryType != Pipelines.PipelineConstants.SelfAlias)
{
var definition = LoadAction(executionContext, action);
if (definition.Data.Execution.HasPre)
{
Trace.Info($"Add 'pre' execution for {action.Id}");
// Root Step
if (depth < 1)
{
var actionRunner = HostContext.CreateService<IActionRunner>();
actionRunner.Action = action;
actionRunner.Stage = ActionRunStage.Pre;
actionRunner.Condition = definition.Data.Execution.InitCondition;
state.PreStepTracker[action.Id] = actionRunner;
}
// Embedded Step
else
{
if (!_cachedEmbeddedPreSteps.ContainsKey(parentStepId))
{
_cachedEmbeddedPreSteps[parentStepId] = new List<Pipelines.ActionStep>();
}
// Clone action so we can modify the condition without affecting the original
var clonedAction = action.Clone() as Pipelines.ActionStep;
clonedAction.Condition = definition.Data.Execution.InitCondition;
_cachedEmbeddedPreSteps[parentStepId].Add(clonedAction);
}
}
if (definition.Data.Execution.HasPost && depth > 0)
{
if (!_cachedEmbeddedPostSteps.ContainsKey(parentStepId))
{
// If we haven't done so already, add the parent to the post steps
_cachedEmbeddedPostSteps[parentStepId] = new Stack<Pipelines.ActionStep>();
}
// Clone action so we can modify the condition without affecting the original
var clonedAction = action.Clone() as Pipelines.ActionStep;
clonedAction.Condition = definition.Data.Execution.CleanupCondition;
_cachedEmbeddedPostSteps[parentStepId].Push(clonedAction);
}
}
else if (depth > 0)
{
// if we're in a composite action and haven't loaded the local action yet
// we assume it has a post step
if (!_cachedEmbeddedPostSteps.ContainsKey(parentStepId))
{
// If we haven't done so already, add the parent to the post steps
_cachedEmbeddedPostSteps[parentStepId] = new Stack<Pipelines.ActionStep>();
}
// Clone action so we can modify the condition without affecting the original
var clonedAction = action.Clone() as Pipelines.ActionStep;
_cachedEmbeddedPostSteps[parentStepId].Push(clonedAction);
}
}
}
return state;
}
/// <summary>
/// Legacy (non-batched) action resolution. Each composite resolves its
/// sub-actions individually, with no cross-depth deduplication.
/// Used when the BatchActionResolution feature flag is disabled.
/// </summary>
private async Task<PrepareActionsState> PrepareActionsRecursiveLegacyAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable<Pipelines.ActionStep> actions, Int32 depth = 0, Guid parentStepId = default(Guid))
{
ArgUtil.NotNull(executionContext, nameof(executionContext));
if (depth > Constants.CompositeActionsMaxDepth)
@@ -255,7 +449,7 @@ namespace GitHub.Runner.Worker
}
else if (setupInfo != null && setupInfo.Steps != null && setupInfo.Steps.Count > 0)
{
state = await PrepareActionsRecursiveAsync(executionContext, state, setupInfo.Steps, depth + 1, action.Id);
state = await PrepareActionsRecursiveLegacyAsync(executionContext, state, setupInfo.Steps, depth + 1, action.Id);
}
var repoAction = action.Reference as Pipelines.RepositoryPathReference;
if (repoAction.RepositoryType != Pipelines.PipelineConstants.SelfAlias)
@@ -686,6 +880,11 @@ namespace GitHub.Runner.Worker
return new Dictionary<string, WebApi.ActionDownloadInfo>();
}
// Pass lockfile dependencies to Launch when present, so it can
// perform ref-scoped policy matching with the original refs.
var deps = executionContext.Global.ActionsDependencies;
IList<string> dependencies = (deps != null && deps.Count > 0) ? deps : null;
// Resolve download info
var launchServer = HostContext.GetService<ILaunchServer>();
var jobServer = HostContext.GetService<IJobServer>();
@@ -697,7 +896,7 @@ namespace GitHub.Runner.Worker
if (MessageUtil.IsRunServiceJob(executionContext.Global.Variables.Get(Constants.Variables.System.JobRequestType)))
{
var displayHelpfulActionsDownloadErrors = executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.DisplayHelpfulActionsDownloadErrors) ?? false;
actionDownloadInfos = await launchServer.ResolveActionsDownloadInfoAsync(executionContext.Global.Plan.PlanId, executionContext.Root.Id, new WebApi.ActionReferenceList { Actions = actionReferences }, executionContext.CancellationToken, displayHelpfulActionsDownloadErrors);
actionDownloadInfos = await launchServer.ResolveActionsDownloadInfoAsync(executionContext.Global.Plan.PlanId, executionContext.Root.Id, new WebApi.ActionReferenceList { Actions = actionReferences, Dependencies = dependencies }, executionContext.CancellationToken, displayHelpfulActionsDownloadErrors);
}
else
{
@@ -762,6 +961,33 @@ namespace GitHub.Runner.Worker
return actionDownloadInfos.Actions;
}
/// <summary>
/// Only resolves actions not already in resolvedDownloadInfos.
/// Results are cached for reuse at deeper recursion levels.
/// </summary>
private async Task ResolveNewActionsAsync(IExecutionContext executionContext, List<Pipelines.ActionStep> actions, Dictionary<string, WebApi.ActionDownloadInfo> resolvedDownloadInfos)
{
var actionsToResolve = new List<Pipelines.ActionStep>();
var pendingKeys = new HashSet<string>(StringComparer.Ordinal);
foreach (var action in actions)
{
var lookupKey = GetDownloadInfoLookupKey(action);
if (!string.IsNullOrEmpty(lookupKey) && !resolvedDownloadInfos.ContainsKey(lookupKey) && pendingKeys.Add(lookupKey))
{
actionsToResolve.Add(action);
}
}
if (actionsToResolve.Count > 0)
{
var downloadInfos = await GetDownloadInfoAsync(executionContext, actionsToResolve);
foreach (var kvp in downloadInfos)
{
resolvedDownloadInfos[kvp.Key] = kvp.Value;
}
}
}
private async Task DownloadRepositoryActionAsync(IExecutionContext executionContext, WebApi.ActionDownloadInfo downloadInfo)
{
Trace.Entering();
@@ -1146,16 +1372,29 @@ namespace GitHub.Runner.Worker
return $"{repositoryReference.Name}@{repositoryReference.Ref}";
}
private AuthenticationHeaderValue CreateAuthHeader(string token)
private AuthenticationHeaderValue CreateAuthHeader(IExecutionContext executionContext, string downloadUrl, string token)
{
if (string.IsNullOrEmpty(token))
{
return null;
}
var base64EncodingToken = Convert.ToBase64String(Encoding.UTF8.GetBytes($"x-access-token:{token}"));
HostContext.SecretMasker.AddValue(base64EncodingToken);
return new AuthenticationHeaderValue("Basic", base64EncodingToken);
if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.UseBearerTokenForCodeload) == true &&
Uri.TryCreate(downloadUrl, UriKind.Absolute, out var parsedUrl) &&
!string.IsNullOrEmpty(parsedUrl?.Host) &&
!string.IsNullOrEmpty(parsedUrl?.PathAndQuery) &&
(parsedUrl.Host.StartsWith("codeload.", StringComparison.OrdinalIgnoreCase) || parsedUrl.PathAndQuery.StartsWith("/_codeload/", StringComparison.OrdinalIgnoreCase)))
{
Trace.Info("Using Bearer token for action archive download directly to codeload.");
return new AuthenticationHeaderValue("Bearer", token);
}
else
{
Trace.Info("Using Basic token for action archive download.");
var base64EncodingToken = Convert.ToBase64String(Encoding.UTF8.GetBytes($"x-access-token:{token}"));
HostContext.SecretMasker.AddValue(base64EncodingToken);
return new AuthenticationHeaderValue("Basic", base64EncodingToken);
}
}
private async Task DownloadRepositoryArchive(IExecutionContext executionContext, string downloadUrl, string downloadAuthToken, string archiveFile)
@@ -1180,7 +1419,7 @@ namespace GitHub.Runner.Worker
using (var httpClientHandler = HostContext.CreateHttpClientHandler())
using (var httpClient = new HttpClient(httpClientHandler))
{
httpClient.DefaultRequestHeaders.Authorization = CreateAuthHeader(downloadAuthToken);
httpClient.DefaultRequestHeaders.Authorization = CreateAuthHeader(executionContext, downloadUrl, downloadAuthToken);
httpClient.DefaultRequestHeaders.UserAgent.AddRange(HostContext.UserAgents);
using (var response = await httpClient.GetAsync(downloadUrl))
@@ -316,7 +316,6 @@ namespace GitHub.Runner.Worker
Schema = _actionManifestSchema,
// TODO: Switch to real tracewriter for cutover
TraceWriter = new GitHub.Actions.WorkflowParser.ObjectTemplating.EmptyTraceWriter(),
AllowCaseFunction = false,
};
// Expression values from execution context
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
@@ -315,7 +315,6 @@ namespace GitHub.Runner.Worker
maxBytes: 10 * 1024 * 1024),
Schema = _actionManifestSchema,
TraceWriter = executionContext.ToTemplateTraceWriter(),
AllowCaseFunction = false,
};
// Expression values from execution context
+183 -47
View File
@@ -1,7 +1,10 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Sockets;
using System.Text;
using System.Threading;
@@ -9,6 +12,9 @@ using System.Threading.Tasks;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
using Microsoft.DevTunnels.Connections;
using Microsoft.DevTunnels.Contracts;
using Microsoft.DevTunnels.Management;
using Newtonsoft.Json;
namespace GitHub.Runner.Worker.Dap
@@ -30,10 +36,10 @@ namespace GitHub.Runner.Worker.Dap
/// </summary>
public sealed class DapDebugger : RunnerService, IDapDebugger
{
private const int _defaultPort = 4711;
private const int _defaultTimeoutMinutes = 15;
private const string _portEnvironmentVariable = "ACTIONS_RUNNER_DAP_PORT";
private const string _timeoutEnvironmentVariable = "ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT";
private const int _defaultTunnelConnectTimeoutSeconds = 30;
private const string _tunnelConnectTimeoutSeconds = "ACTIONS_RUNNER_DAP_TUNNEL_CONNECT_TIMEOUT_SECONDS";
private const string _contentLengthHeader = "Content-Length: ";
private const int _maxMessageSize = 10 * 1024 * 1024; // 10 MB
private const int _maxHeaderLineLength = 8192; // 8 KB
@@ -58,6 +64,21 @@ namespace GitHub.Runner.Worker.Dap
private CancellationTokenRegistration? _cancellationRegistration;
private bool _isFirstStep = true;
// Dev Tunnel relay host for remote debugging
private TunnelRelayTunnelHost _tunnelRelayHost;
private IWebSocketDapBridge _webSocketBridge;
// Cancellation source for the connection loop, cancelled in StopAsync
// so AcceptTcpClientAsync unblocks cleanly without relying on listener disposal.
private CancellationTokenSource _loopCts;
// When true, skip tunnel relay startup (unit tests only)
internal bool SkipTunnelRelay { get; set; }
// When true, skip the public websocket bridge and expose the raw DAP
// listener directly on the configured tunnel port (unit tests only).
internal bool SkipWebSocketBridge { get; set; }
// Synchronization for step execution
private TaskCompletionSource<DapCommand> _commandTcs;
private readonly object _stateLock = new object();
@@ -92,6 +113,7 @@ namespace GitHub.Runner.Worker.Dap
_state == DapSessionState.Running;
internal DapSessionState State => _state;
internal int InternalDapPort => (_listener?.LocalEndpoint as IPEndPoint)?.Port ?? 0;
public override void Initialize(IHostContext hostContext)
{
@@ -101,22 +123,48 @@ namespace GitHub.Runner.Worker.Dap
Trace.Info("DapDebugger initialized");
}
public Task StartAsync(IExecutionContext jobContext)
public async Task StartAsync(IExecutionContext jobContext)
{
ArgUtil.NotNull(jobContext, nameof(jobContext));
var port = ResolvePort();
var debuggerConfig = jobContext.Global.Debugger;
Trace.Info($"Starting DAP debugger on port {port}");
if (!debuggerConfig.HasValidTunnel)
{
throw new ArgumentException(
"Debugger requires valid tunnel configuration (tunnelId, clusterId, hostToken, port).");
}
Trace.Info($"Starting DAP debugger on port {debuggerConfig.Tunnel.Port}");
_jobContext = jobContext;
_readyTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_listener = new TcpListener(IPAddress.Loopback, port);
var dapPort = SkipWebSocketBridge ? debuggerConfig.Tunnel.Port : 0;
_listener = new TcpListener(IPAddress.Loopback, dapPort);
_listener.Start();
Trace.Info($"DAP debugger listening on {_listener.LocalEndpoint}");
if (SkipWebSocketBridge)
{
Trace.Info($"DAP debugger listening on {_listener.LocalEndpoint}");
}
else
{
Trace.Info($"Internal DAP debugger listening on {_listener.LocalEndpoint}");
_webSocketBridge = HostContext.CreateService<IWebSocketDapBridge>();
_webSocketBridge.Start(debuggerConfig.Tunnel.Port, InternalDapPort);
}
// Start Dev Tunnel relay so remote clients reach the local DAP port.
// The relay is torn down explicitly in StopAsync (after the DAP session
// is closed) so we do NOT pass the job cancellation token here — that
// would race with the DAP shutdown and drop the transport mid-protocol.
if (!SkipTunnelRelay)
{
await StartTunnelRelayAsync(debuggerConfig);
}
_state = DapSessionState.WaitingForConnection;
_connectionLoopTask = ConnectionLoopAsync(jobContext.CancellationToken);
_loopCts = CancellationTokenSource.CreateLinkedTokenSource(jobContext.CancellationToken);
_connectionLoopTask = ConnectionLoopAsync(_loopCts.Token);
_cancellationRegistration = jobContext.CancellationToken.Register(() =>
{
@@ -125,8 +173,44 @@ namespace GitHub.Runner.Worker.Dap
_commandTcs?.TrySetResult(DapCommand.Disconnect);
});
Trace.Info($"DAP debugger started on port {port}");
return Task.CompletedTask;
Trace.Info($"DAP debugger started on port {debuggerConfig.Tunnel.Port}");
}
private async Task StartTunnelRelayAsync(DebuggerConfig config)
{
Trace.Info($"Starting Dev Tunnel relay (tunnel={config.Tunnel.TunnelId}, cluster={config.Tunnel.ClusterId})");
var userAgents = HostContext.UserAgents.ToArray();
var httpHandler = HostContext.CreateHttpClientHandler();
httpHandler.AllowAutoRedirect = false;
var managementClient = new TunnelManagementClient(
userAgents,
() => Task.FromResult<AuthenticationHeaderValue>(new AuthenticationHeaderValue("tunnel", config.Tunnel.HostToken)),
tunnelServiceUri: null,
httpHandler);
var tunnel = new Tunnel
{
TunnelId = config.Tunnel.TunnelId,
ClusterId = config.Tunnel.ClusterId,
AccessTokens = new Dictionary<string, string>
{
[TunnelAccessScopes.Host] = config.Tunnel.HostToken
},
Ports = new[]
{
new TunnelPort { PortNumber = config.Tunnel.Port }
},
};
_tunnelRelayHost = new TunnelRelayTunnelHost(managementClient, HostContext.GetTrace("DevTunnelRelay").Source);
var tunnelConnectTimeoutSeconds = ResolveTunnelConnectTimeout();
using var connectCts = new CancellationTokenSource(TimeSpan.FromSeconds(tunnelConnectTimeoutSeconds));
Trace.Info($"Connecting to Dev Tunnel relay (timeout: {tunnelConnectTimeoutSeconds}s)");
await _tunnelRelayHost.ConnectAsync(tunnel, connectCts.Token);
Trace.Info("Dev Tunnel relay started");
}
public async Task WaitUntilReadyAsync()
@@ -180,31 +264,74 @@ namespace GitHub.Runner.Worker.Dap
_cancellationRegistration = null;
}
if (_state != DapSessionState.NotStarted)
try
{
try
if (_listener != null || _tunnelRelayHost != null || _connectionLoopTask != null)
{
Trace.Info("Stopping DAP debugger");
CleanupConnection();
try { _listener?.Stop(); }
catch { /* best effort */ }
if (_connectionLoopTask != null)
{
try
{
await Task.WhenAny(_connectionLoopTask, Task.Delay(5000));
}
catch { /* best effort */ }
}
}
catch (Exception ex)
// Tear down Dev Tunnel relay FIRST — it may hold connections to the
// local port and must be fully disposed before we release the listener,
// otherwise the next worker can't bind the same port.
if (_tunnelRelayHost != null)
{
Trace.Error("Error stopping DAP debugger");
Trace.Error(ex);
Trace.Info("Stopping Dev Tunnel relay");
var disposeTask = _tunnelRelayHost.DisposeAsync().AsTask();
if (await Task.WhenAny(disposeTask, Task.Delay(10_000)) != disposeTask)
{
Trace.Warning("Dev Tunnel relay dispose timed out after 10s");
}
else
{
Trace.Info("Dev Tunnel relay stopped");
}
_tunnelRelayHost = null;
}
if (_webSocketBridge != null)
{
Trace.Info("Stopping WebSocket DAP bridge");
var shutdownTask = _webSocketBridge.ShutdownAsync();
if (await Task.WhenAny(shutdownTask, Task.Delay(5_000)) != shutdownTask)
{
Trace.Warning("WebSocket DAP bridge shutdown timed out after 5s");
_ = shutdownTask.ContinueWith(
t => Trace.Error($"WebSocket DAP bridge shutdown faulted: {t.Exception?.GetBaseException().Message}"),
TaskContinuationOptions.OnlyOnFaulted);
}
else
{
Trace.Info("WebSocket DAP bridge stopped");
}
_webSocketBridge = null;
}
CleanupConnection();
// Cancel the connection loop first so AcceptTcpClientAsync unblocks
// cleanly, then stop the listener once nothing is using it.
try { _loopCts?.Cancel(); }
catch { /* best effort */ }
try { _listener?.Stop(); }
catch { /* best effort */ }
if (_connectionLoopTask != null)
{
try
{
await Task.WhenAny(_connectionLoopTask, Task.Delay(5000));
}
catch { /* best effort */ }
}
}
catch (Exception ex)
{
Trace.Error("Error stopping DAP debugger");
Trace.Error(ex);
}
lock (_stateLock)
@@ -221,6 +348,9 @@ namespace GitHub.Runner.Worker.Dap
_stream = null;
_readyTcs = null;
_connectionLoopTask = null;
_loopCts?.Dispose();
_loopCts = null;
_webSocketBridge = null;
}
public async Task OnStepStartingAsync(IStep step)
@@ -398,12 +528,7 @@ namespace GitHub.Runner.Worker.Dap
try
{
Trace.Info("Waiting for debug client connection...");
_client = await _listener.AcceptTcpClientAsync();
if (cancellationToken.IsCancellationRequested)
{
break;
}
_client = await _listener.AcceptTcpClientAsync(cancellationToken);
_stream = _client.GetStream();
var remoteEndPoint = _client.Client.RemoteEndPoint;
@@ -418,6 +543,10 @@ namespace GitHub.Runner.Worker.Dap
HandleClientDisconnected();
CleanupConnection();
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
CleanupConnection();
@@ -427,6 +556,13 @@ namespace GitHub.Runner.Worker.Dap
break;
}
// If the listener has been stopped, don't retry.
if (_listener == null || !_listener.Server.IsBound)
{
Trace.Info("Listener stopped, exiting connection loop");
break;
}
Trace.Error("Debugger connection error");
Trace.Error(ex);
@@ -1272,18 +1408,6 @@ namespace GitHub.Runner.Worker.Dap
};
}
internal int ResolvePort()
{
var portEnv = Environment.GetEnvironmentVariable(_portEnvironmentVariable);
if (!string.IsNullOrEmpty(portEnv) && int.TryParse(portEnv, out var customPort) && customPort > 1024 && customPort <= 65535)
{
Trace.Info($"Using custom DAP port {customPort} from {_portEnvironmentVariable}");
return customPort;
}
return _defaultPort;
}
internal int ResolveTimeout()
{
var timeoutEnv = Environment.GetEnvironmentVariable(_timeoutEnvironmentVariable);
@@ -1295,5 +1419,17 @@ namespace GitHub.Runner.Worker.Dap
return _defaultTimeoutMinutes;
}
internal int ResolveTunnelConnectTimeout()
{
var raw = Environment.GetEnvironmentVariable(_tunnelConnectTimeoutSeconds);
if (!string.IsNullOrEmpty(raw) && int.TryParse(raw, out var customTimeout) && customTimeout > 0)
{
Trace.Info($"Using custom tunnel connect timeout {customTimeout}s from {_tunnelConnectTimeoutSeconds}");
return customTimeout;
}
return _defaultTunnelConnectTimeoutSeconds;
}
}
}
+33
View File
@@ -0,0 +1,33 @@
using GitHub.DistributedTask.Pipelines;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Consolidated runtime configuration for the job debugger.
/// Populated once from the acquire response and owned by <see cref="GlobalContext"/>.
/// </summary>
public sealed class DebuggerConfig
{
public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel)
{
Enabled = enabled;
Tunnel = tunnel;
}
/// <summary>Whether the debugger is enabled for this job.</summary>
public bool Enabled { get; }
/// <summary>
/// Dev Tunnel details for remote debugging.
/// Required when <see cref="Enabled"/> is true.
/// </summary>
public DebuggerTunnelInfo Tunnel { get; }
/// <summary>Whether the tunnel configuration is complete and valid.</summary>
public bool HasValidTunnel => Tunnel != null
&& !string.IsNullOrEmpty(Tunnel.TunnelId)
&& !string.IsNullOrEmpty(Tunnel.ClusterId)
&& !string.IsNullOrEmpty(Tunnel.HostToken)
&& Tunnel.Port >= 1024 && Tunnel.Port <= 65535;
}
}
@@ -0,0 +1,12 @@
using System.Threading.Tasks;
using GitHub.Runner.Common;
namespace GitHub.Runner.Worker.Dap
{
[ServiceLocator(Default = typeof(WebSocketDapBridge))]
public interface IWebSocketDapBridge : IRunnerService
{
void Start(int listenPort, int targetPort);
Task ShutdownAsync();
}
}
+839
View File
@@ -0,0 +1,839 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using GitHub.Runner.Common;
namespace GitHub.Runner.Worker.Dap
{
internal sealed class WebSocketDapBridge : RunnerService, IWebSocketDapBridge
{
internal enum IncomingStreamPrefixKind
{
Unknown,
HttpWebSocketUpgrade,
PreUpgradedWebSocket,
WebSocketReservedBits,
Http2Preface,
TlsClientHello,
}
private const int _bufferSize = 32 * 1024;
private const int _maxHeaderLineLength = 8 * 1024;
private const int _defaultMaxInboundMessageSize = 10 * 1024 * 1024; // 10 MB
private static readonly TimeSpan _keepAliveInterval = TimeSpan.FromSeconds(30);
private static readonly TimeSpan _closeTimeout = TimeSpan.FromSeconds(5);
private static readonly TimeSpan _handshakeTimeout = TimeSpan.FromSeconds(10);
private const string _webSocketAcceptMagic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
private const int _maxHeaderCount = 64;
private static readonly byte[] _headerEndMarker = new byte[] { (byte)'\r', (byte)'\n', (byte)'\r', (byte)'\n' };
private int _listenPort;
private int _targetPort;
private TcpListener _listener;
private CancellationTokenSource _loopCts;
private Task _acceptLoopTask;
public int MaxInboundMessageSize { get; set; } = _defaultMaxInboundMessageSize;
internal int ListenPort => (_listener?.LocalEndpoint as IPEndPoint)?.Port ?? 0;
public void Start(int listenPort, int targetPort)
{
if (_listener != null)
{
throw new InvalidOperationException("WebSocket DAP bridge already started.");
}
_listenPort = listenPort;
_targetPort = targetPort;
_listener = new TcpListener(IPAddress.Loopback, _listenPort);
_listener.Start();
_loopCts = new CancellationTokenSource();
_acceptLoopTask = AcceptLoopAsync(_loopCts.Token);
Trace.Info($"WebSocket DAP bridge listening on {_listener.LocalEndpoint} -> 127.0.0.1:{_targetPort}");
}
public async Task ShutdownAsync()
{
_loopCts?.Cancel();
try
{
_listener?.Stop();
}
catch (Exception ex)
{
Trace.Warning($"Error stopping listener during shutdown ({ex.GetType().Name})");
}
if (_acceptLoopTask != null)
{
try
{
await _acceptLoopTask;
}
catch (OperationCanceledException)
{
// expected on shutdown
}
}
_loopCts?.Dispose();
_loopCts = null;
_listener = null;
_acceptLoopTask = null;
}
private async Task AcceptLoopAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
TcpClient client = null;
try
{
client = await _listener.AcceptTcpClientAsync(cancellationToken);
client.NoDelay = true;
await HandleClientAsync(client, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
client?.Dispose();
Trace.Error($"WebSocket DAP bridge connection error");
Trace.Error(ex);
}
finally
{
client?.Dispose();
}
}
Trace.Info("WebSocket DAP bridge accept loop ended");
}
private async Task HandleClientAsync(TcpClient incomingClient, CancellationToken cancellationToken)
{
using (var incomingStream = incomingClient.GetStream())
{
Trace.Info($"WebSocket DAP bridge accepted client {incomingClient.Client.RemoteEndPoint}");
WebSocket webSocket;
using (var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
{
handshakeCts.CancelAfter(_handshakeTimeout);
try
{
webSocket = await AcceptWebSocketAsync(incomingStream, handshakeCts.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
Trace.Warning("WebSocket handshake timed out");
return;
}
}
if (webSocket == null)
{
return;
}
using (webSocket)
using (var dapClient = new TcpClient())
{
dapClient.NoDelay = true;
await dapClient.ConnectAsync(IPAddress.Loopback, _targetPort, cancellationToken);
using (var dapStream = dapClient.GetStream())
using (var sessionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
{
var proxyToken = sessionCts.Token;
var wsToTcpTask = PumpWebSocketToTcpAsync(webSocket, dapStream, proxyToken);
var tcpToWsTask = PumpTcpToWebSocketAsync(dapStream, webSocket, proxyToken);
await Task.WhenAny(wsToTcpTask, tcpToWsTask);
sessionCts.Cancel();
await CloseWebSocketAsync(webSocket);
try
{
await Task.WhenAll(wsToTcpTask, tcpToWsTask);
}
catch (OperationCanceledException) when (proxyToken.IsCancellationRequested)
{
// expected during shutdown
}
catch (Exception ex)
{
Trace.Warning($"DAP protocol error: {ex}");
}
}
}
}
}
private async Task<WebSocket> AcceptWebSocketAsync(NetworkStream stream, CancellationToken cancellationToken)
{
var initialBytes = await ReadInitialBytesAsync(stream, cancellationToken);
if (initialBytes == null || initialBytes.Length == 0)
{
return null;
}
var prefixKind = ClassifyIncomingStreamPrefix(initialBytes);
if (prefixKind == IncomingStreamPrefixKind.PreUpgradedWebSocket)
{
Trace.Info($"Treating incoming tunnel stream as an already-upgraded websocket connection ({DescribeInitialBytes(initialBytes)})");
return WebSocket.CreateFromStream(
new ReplayableStream(stream, initialBytes),
isServer: true,
subProtocol: null,
keepAliveInterval: _keepAliveInterval);
}
if (prefixKind != IncomingStreamPrefixKind.HttpWebSocketUpgrade)
{
Trace.Warning($"Unsupported debugger tunnel stream prefix ({prefixKind}): {DescribeInitialBytes(initialBytes)}");
return null;
}
var handshakeStream = new ReplayableStream(stream, initialBytes);
var requestLine = await ReadLineAsync(handshakeStream, cancellationToken);
if (string.IsNullOrEmpty(requestLine))
{
return null;
}
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
while (!cancellationToken.IsCancellationRequested)
{
if (headers.Count >= _maxHeaderCount)
{
Trace.Warning($"Rejected WebSocket request with too many headers (>{_maxHeaderCount})");
await WriteHttpErrorAsync(stream, HttpStatusCode.BadRequest, "Too many headers.", cancellationToken);
return null;
}
var line = await ReadLineAsync(handshakeStream, cancellationToken);
if (line == null)
{
return null;
}
if (line.Length == 0)
{
break;
}
var separatorIndex = line.IndexOf(':');
if (separatorIndex <= 0)
{
await WriteHttpErrorAsync(stream, HttpStatusCode.BadRequest, "Invalid HTTP header.", cancellationToken);
return null;
}
var headerName = line.Substring(0, separatorIndex).Trim();
var headerValue = line.Substring(separatorIndex + 1).Trim();
if (headers.TryGetValue(headerName, out var existingValue))
{
headers[headerName] = $"{existingValue}, {headerValue}";
}
else
{
headers[headerName] = headerValue;
}
}
if (!IsValidWebSocketRequest(requestLine, headers))
{
var method = requestLine.Split(' ')[0];
Trace.Info($"Rejected non-websocket request (method={method})");
await WriteHttpErrorAsync(stream, HttpStatusCode.BadRequest, "Expected a websocket upgrade request.", cancellationToken);
return null;
}
if (!headers.TryGetValue("Sec-WebSocket-Version", out var webSocketVersion) ||
!string.Equals(webSocketVersion.Trim(), "13", StringComparison.Ordinal))
{
Trace.Warning("Rejected WebSocket request with unsupported version");
await WriteHttpErrorAsync(stream, (HttpStatusCode)426, "Unsupported WebSocket version. Expected: 13.", cancellationToken);
return null;
}
var webSocketKey = headers["Sec-WebSocket-Key"];
if (!IsValidWebSocketKey(webSocketKey))
{
Trace.Warning("Rejected WebSocket request with invalid Sec-WebSocket-Key");
await WriteHttpErrorAsync(stream, HttpStatusCode.BadRequest, "Invalid Sec-WebSocket-Key.", cancellationToken);
return null;
}
var acceptValue = ComputeAcceptValue(webSocketKey);
var responseBytes = Encoding.ASCII.GetBytes(
"HTTP/1.1 101 Switching Protocols\r\n" +
"Connection: Upgrade\r\n" +
"Upgrade: websocket\r\n" +
$"Sec-WebSocket-Accept: {acceptValue}\r\n" +
"\r\n");
await handshakeStream.WriteAsync(responseBytes, 0, responseBytes.Length, cancellationToken);
await handshakeStream.FlushAsync(cancellationToken);
Trace.Info("WebSocket DAP bridge completed websocket handshake");
return WebSocket.CreateFromStream(handshakeStream, isServer: true, subProtocol: null, keepAliveInterval: _keepAliveInterval);
}
private async Task PumpWebSocketToTcpAsync(WebSocket source, NetworkStream destination, CancellationToken cancellationToken)
{
var buffer = new byte[_bufferSize];
while (!cancellationToken.IsCancellationRequested)
{
using (var messageStream = new MemoryStream())
{
WebSocketReceiveResult result;
do
{
result = await source.ReceiveAsync(new ArraySegment<byte>(buffer), cancellationToken);
if (result.MessageType == WebSocketMessageType.Close)
{
return;
}
if (result.MessageType != WebSocketMessageType.Binary &&
result.MessageType != WebSocketMessageType.Text)
{
break;
}
if (result.Count > 0)
{
if (messageStream.Length + result.Count > MaxInboundMessageSize)
{
Trace.Warning($"WebSocket message exceeds maximum allowed size of {MaxInboundMessageSize} bytes, closing connection");
await source.CloseAsync(
WebSocketCloseStatus.MessageTooBig,
$"Message exceeds {MaxInboundMessageSize} byte limit",
CancellationToken.None);
return;
}
messageStream.Write(buffer, 0, result.Count);
}
}
while (!result.EndOfMessage && !cancellationToken.IsCancellationRequested);
if (result.MessageType != WebSocketMessageType.Binary &&
result.MessageType != WebSocketMessageType.Text)
{
continue;
}
var messageBytes = messageStream.ToArray();
if (messageBytes.Length == 0)
{
continue;
}
var contentLengthHeader = Encoding.ASCII.GetBytes($"Content-Length: {messageBytes.Length}\r\n\r\n");
await destination.WriteAsync(contentLengthHeader, 0, contentLengthHeader.Length, cancellationToken);
await destination.WriteAsync(messageBytes, 0, messageBytes.Length, cancellationToken);
await destination.FlushAsync(cancellationToken);
}
}
}
private static async Task PumpTcpToWebSocketAsync(NetworkStream source, WebSocket destination, CancellationToken cancellationToken)
{
var readBuffer = new byte[_bufferSize];
var dapBuffer = new List<byte>();
while (!cancellationToken.IsCancellationRequested)
{
var bytesRead = await source.ReadAsync(readBuffer, 0, readBuffer.Length, cancellationToken);
if (bytesRead == 0)
{
break;
}
dapBuffer.AddRange(new ArraySegment<byte>(readBuffer, 0, bytesRead));
while (TryParseDapMessage(dapBuffer, out var messageBody))
{
await destination.SendAsync(
new ArraySegment<byte>(messageBody),
WebSocketMessageType.Text,
endOfMessage: true,
cancellationToken);
}
}
}
private static bool TryParseDapMessage(List<byte> buffer, out byte[] messageBody)
{
messageBody = null;
var headerEndIndex = FindSequence(buffer, _headerEndMarker);
if (headerEndIndex == -1)
{
return false;
}
var headerBytes = buffer.GetRange(0, headerEndIndex).ToArray();
var headerText = Encoding.ASCII.GetString(headerBytes);
var contentLength = -1;
foreach (var line in headerText.Split(new[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries))
{
if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase))
{
var valueStart = line.IndexOf(':') + 1;
if (int.TryParse(line.Substring(valueStart).Trim(), out var parsedLength))
{
contentLength = parsedLength;
break;
}
}
}
if (contentLength < 0)
{
throw new InvalidOperationException("DAP message missing or unparseable Content-Length header; tearing down session.");
}
var messageStart = headerEndIndex + 4;
var messageEnd = messageStart + contentLength;
if (buffer.Count < messageEnd)
{
return false;
}
messageBody = buffer.GetRange(messageStart, contentLength).ToArray();
buffer.RemoveRange(0, messageEnd);
return true;
}
private static int FindSequence(List<byte> buffer, byte[] sequence)
{
if (buffer.Count < sequence.Length)
{
return -1;
}
for (int i = 0; i <= buffer.Count - sequence.Length; i++)
{
var match = true;
for (int j = 0; j < sequence.Length; j++)
{
if (buffer[i + j] != sequence[j])
{
match = false;
break;
}
}
if (match)
{
return i;
}
}
return -1;
}
private static bool IsValidWebSocketRequest(string requestLine, IDictionary<string, string> headers)
{
if (string.IsNullOrWhiteSpace(requestLine))
{
return false;
}
var requestLineParts = requestLine.Split(' ');
if (requestLineParts.Length < 3 || !string.Equals(requestLineParts[0], "GET", StringComparison.OrdinalIgnoreCase))
{
return false;
}
return HeaderContainsToken(headers, "Connection", "Upgrade") &&
HeaderContainsToken(headers, "Upgrade", "websocket") &&
headers.ContainsKey("Sec-WebSocket-Key");
}
private static bool HeaderContainsToken(IDictionary<string, string> headers, string headerName, string expectedToken)
{
if (!headers.TryGetValue(headerName, out var headerValue) || string.IsNullOrWhiteSpace(headerValue))
{
return false;
}
return headerValue
.Split(',')
.Select(token => token.Trim())
.Any(token => string.Equals(token, expectedToken, StringComparison.OrdinalIgnoreCase));
}
private static string ComputeAcceptValue(string webSocketKey)
{
using (var sha1 = SHA1.Create())
{
var inputBytes = Encoding.ASCII.GetBytes($"{webSocketKey}{_webSocketAcceptMagic}");
var hashBytes = sha1.ComputeHash(inputBytes);
return Convert.ToBase64String(hashBytes);
}
}
private static bool IsValidWebSocketKey(string key)
{
if (string.IsNullOrEmpty(key) || key.IndexOfAny(new[] { '\r', '\n' }) >= 0)
{
return false;
}
try
{
var decoded = Convert.FromBase64String(key);
return decoded.Length == 16;
}
catch (FormatException)
{
return false;
}
}
private static async Task<string> ReadLineAsync(Stream stream, CancellationToken cancellationToken)
{
var lineBuilder = new StringBuilder();
var buffer = new byte[1];
var previousWasCarriageReturn = false;
while (true)
{
var bytesRead = await stream.ReadAsync(buffer, 0, 1, cancellationToken);
if (bytesRead == 0)
{
return lineBuilder.Length > 0 ? lineBuilder.ToString() : null;
}
var currentChar = (char)buffer[0];
if (currentChar == '\n' && previousWasCarriageReturn)
{
if (lineBuilder.Length > 0 && lineBuilder[lineBuilder.Length - 1] == '\r')
{
lineBuilder.Length--;
}
return lineBuilder.ToString();
}
previousWasCarriageReturn = currentChar == '\r';
lineBuilder.Append(currentChar);
if (lineBuilder.Length > _maxHeaderLineLength)
{
throw new InvalidDataException($"HTTP header line exceeds maximum length of {_maxHeaderLineLength}");
}
}
}
private static async Task<byte[]> ReadInitialBytesAsync(NetworkStream stream, CancellationToken cancellationToken)
{
var buffer = new byte[4];
var totalRead = 0;
while (totalRead < buffer.Length)
{
var bytesRead = await stream.ReadAsync(buffer, totalRead, buffer.Length - totalRead, cancellationToken);
if (bytesRead == 0)
{
break;
}
totalRead += bytesRead;
}
if (totalRead == 0)
{
return Array.Empty<byte>();
}
if (totalRead == buffer.Length)
{
return buffer;
}
var initialBytes = new byte[totalRead];
Array.Copy(buffer, initialBytes, totalRead);
return initialBytes;
}
internal static IncomingStreamPrefixKind ClassifyIncomingStreamPrefix(byte[] initialBytes)
{
if (LooksLikeHttpUpgrade(initialBytes))
{
return IncomingStreamPrefixKind.HttpWebSocketUpgrade;
}
if (LooksLikeHttp2Preface(initialBytes))
{
return IncomingStreamPrefixKind.Http2Preface;
}
if (LooksLikeTlsClientHello(initialBytes))
{
return IncomingStreamPrefixKind.TlsClientHello;
}
if (LooksLikeWebSocketFramePrefix(initialBytes, requireReservedBitsClear: false))
{
return HasReservedBitsSet(initialBytes[0])
? IncomingStreamPrefixKind.WebSocketReservedBits
: IncomingStreamPrefixKind.PreUpgradedWebSocket;
}
return IncomingStreamPrefixKind.Unknown;
}
internal static string DescribeInitialBytes(byte[] initialBytes)
{
if (initialBytes == null || initialBytes.Length == 0)
{
return "no bytes read";
}
var hex = BitConverter.ToString(initialBytes);
var ascii = new string(initialBytes.Select(value => value >= 32 && value <= 126 ? (char)value : '.').ToArray());
return $"hex={hex}, ascii=\"{ascii}\"";
}
private static bool LooksLikeHttpUpgrade(byte[] initialBytes)
{
if (initialBytes == null || initialBytes.Length < 4)
{
return false;
}
return initialBytes[0] == (byte)'G' &&
initialBytes[1] == (byte)'E' &&
initialBytes[2] == (byte)'T' &&
initialBytes[3] == (byte)' ';
}
private static bool LooksLikeHttp2Preface(byte[] initialBytes)
{
if (initialBytes == null || initialBytes.Length < 4)
{
return false;
}
return initialBytes[0] == (byte)'P' &&
initialBytes[1] == (byte)'R' &&
initialBytes[2] == (byte)'I' &&
initialBytes[3] == (byte)' ';
}
private static bool LooksLikeTlsClientHello(byte[] initialBytes)
{
if (initialBytes == null || initialBytes.Length < 3)
{
return false;
}
return initialBytes[0] == 0x16 &&
initialBytes[1] == 0x03 &&
initialBytes[2] >= 0x00 &&
initialBytes[2] <= 0x04;
}
private static bool LooksLikeWebSocketFramePrefix(byte[] initialBytes, bool requireReservedBitsClear)
{
if (initialBytes == null || initialBytes.Length < 2)
{
return false;
}
var firstByte = initialBytes[0];
var secondByte = initialBytes[1];
var opcode = firstByte & 0x0F;
var isMasked = (secondByte & 0x80) != 0;
if (!isMasked || !IsSupportedWebSocketOpcode(opcode))
{
return false;
}
return !requireReservedBitsClear || !HasReservedBitsSet(firstByte);
}
private static bool HasReservedBitsSet(byte firstByte)
{
return (firstByte & 0x70) != 0;
}
private static bool IsSupportedWebSocketOpcode(int opcode)
{
switch (opcode)
{
case 0x0:
case 0x1:
case 0x2:
case 0x8:
case 0x9:
case 0xA:
return true;
default:
return false;
}
}
private static async Task WriteHttpErrorAsync(
NetworkStream stream,
HttpStatusCode statusCode,
string message,
CancellationToken cancellationToken)
{
var bodyBytes = Encoding.UTF8.GetBytes(message);
var responseBytes = Encoding.ASCII.GetBytes(
$"HTTP/1.1 {(int)statusCode} {statusCode}\r\n" +
"Connection: close\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n" +
$"Content-Length: {bodyBytes.Length}\r\n" +
"Sec-WebSocket-Version: 13\r\n" +
"\r\n");
await stream.WriteAsync(responseBytes, 0, responseBytes.Length, cancellationToken);
await stream.WriteAsync(bodyBytes, 0, bodyBytes.Length, cancellationToken);
await stream.FlushAsync(cancellationToken);
}
private static async Task CloseWebSocketAsync(WebSocket webSocket)
{
if (webSocket == null)
{
return;
}
if (webSocket.State != WebSocketState.Open &&
webSocket.State != WebSocketState.CloseReceived)
{
return;
}
try
{
using var cts = new CancellationTokenSource(_closeTimeout);
await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, cts.Token);
}
catch (OperationCanceledException)
{
// Graceful close timed out, abort the connection.
webSocket.Abort();
}
catch (WebSocketException)
{
// Peer already disconnected.
}
}
private sealed class ReplayableStream : Stream
{
private readonly Stream _innerStream;
private readonly byte[] _prefixBytes;
private int _prefixOffset;
public ReplayableStream(Stream innerStream, byte[] prefixBytes)
{
_innerStream = innerStream ?? throw new ArgumentNullException(nameof(innerStream));
_prefixBytes = prefixBytes ?? Array.Empty<byte>();
}
public override bool CanRead => _innerStream.CanRead;
public override bool CanSeek => false;
public override bool CanWrite => _innerStream.CanWrite;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override void Flush() => _innerStream.Flush();
public override Task FlushAsync(CancellationToken cancellationToken) => _innerStream.FlushAsync(cancellationToken);
public override int Read(byte[] buffer, int offset, int count)
{
if (TryReadPrefix(buffer, offset, count, out var bytesRead))
{
return bytesRead;
}
return _innerStream.Read(buffer, offset, count);
}
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
if (TryReadPrefix(buffer, offset, count, out var bytesRead))
{
return bytesRead;
}
return await _innerStream.ReadAsync(buffer, offset, count, cancellationToken);
}
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
if (_prefixOffset < _prefixBytes.Length)
{
var bytesToCopy = Math.Min(buffer.Length, _prefixBytes.Length - _prefixOffset);
new ReadOnlySpan<byte>(_prefixBytes, _prefixOffset, bytesToCopy).CopyTo(buffer.Span);
_prefixOffset += bytesToCopy;
return bytesToCopy;
}
return await _innerStream.ReadAsync(buffer, cancellationToken);
}
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => _innerStream.Write(buffer, offset, count);
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
_innerStream.WriteAsync(buffer, offset, count, cancellationToken);
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default) =>
_innerStream.WriteAsync(buffer, cancellationToken);
private bool TryReadPrefix(byte[] buffer, int offset, int count, out int bytesRead)
{
if (_prefixOffset >= _prefixBytes.Length)
{
bytesRead = 0;
return false;
}
bytesRead = Math.Min(count, _prefixBytes.Length - _prefixOffset);
Array.Copy(_prefixBytes, _prefixOffset, buffer, offset, bytesRead);
_prefixOffset += bytesRead;
return true;
}
}
}
}
+8 -8
View File
@@ -875,6 +875,9 @@ namespace GitHub.Runner.Worker
// File table
Global.FileTable = new List<String>(message.FileTable ?? new string[0]);
// Workflow dependencies (lockfile pins)
Global.ActionsDependencies = message.ActionsDependencies;
// What type of job request is running (i.e. Run Service vs. pipelines)
Global.Variables.Set(Constants.Variables.System.JobRequestType, message.MessageType);
@@ -892,15 +895,12 @@ namespace GitHub.Runner.Worker
Trace.Info("Initializing Job context");
var jobContext = new JobContext();
if (Global.Variables.GetBoolean(Constants.Runner.Features.AddCheckRunIdToJobContext) ?? false)
ExpressionValues.TryGetValue("job", out var jobDictionary);
if (jobDictionary != null)
{
ExpressionValues.TryGetValue("job", out var jobDictionary);
if (jobDictionary != null)
foreach (var pair in jobDictionary.AssertDictionary("job"))
{
foreach (var pair in jobDictionary.AssertDictionary("job"))
{
jobContext[pair.Key] = pair.Value;
}
jobContext[pair.Key] = pair.Value;
}
}
ExpressionValues["job"] = jobContext;
@@ -970,7 +970,7 @@ namespace GitHub.Runner.Worker
Global.WriteDebug = Global.Variables.Step_Debug ?? false;
// Debugger enabled flag (from acquire response).
Global.EnableDebugger = message.EnableDebugger;
Global.Debugger = new Dap.DebuggerConfig(message.EnableDebugger, message.DebuggerTunnel);
// Hook up JobServerQueueThrottling event, we will log warning on server tarpit.
_jobServerQueue.JobServerQueueThrottling += JobServerQueueThrottling_EventReceived;
+3 -1
View File
@@ -4,6 +4,7 @@ using GitHub.Actions.RunService.WebApi;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Worker.Container;
using GitHub.Runner.Worker.Dap;
using Newtonsoft.Json.Linq;
using Sdk.RSWebApi.Contracts;
@@ -27,7 +28,7 @@ namespace GitHub.Runner.Worker
public StepsContext StepsContext { get; set; }
public Variables Variables { get; set; }
public bool WriteDebug { get; set; }
public bool EnableDebugger { get; set; }
public DebuggerConfig Debugger { get; set; }
public string InfrastructureFailureCategory { get; set; }
public JObject ContainerHookState { get; set; }
public bool HasTemplateEvaluatorMismatch { get; set; }
@@ -37,5 +38,6 @@ namespace GitHub.Runner.Worker
public HashSet<string> DeprecatedNode20Actions { get; set; }
public HashSet<string> UpgradedToNode24Actions { get; set; }
public HashSet<string> Arm32Node20Actions { get; set; }
public IList<String> ActionsDependencies { get; set; }
}
}
+64
View File
@@ -82,5 +82,69 @@ namespace GitHub.Runner.Worker
}
}
}
public string WorkflowRef
{
get
{
if (this.TryGetValue("workflow_ref", out var value) && value is StringContextData str)
{
return str.Value;
}
return null;
}
set
{
this["workflow_ref"] = value != null ? new StringContextData(value) : null;
}
}
public string WorkflowSha
{
get
{
if (this.TryGetValue("workflow_sha", out var value) && value is StringContextData str)
{
return str.Value;
}
return null;
}
set
{
this["workflow_sha"] = value != null ? new StringContextData(value) : null;
}
}
public string WorkflowRepository
{
get
{
if (this.TryGetValue("workflow_repository", out var value) && value is StringContextData str)
{
return str.Value;
}
return null;
}
set
{
this["workflow_repository"] = value != null ? new StringContextData(value) : null;
}
}
public string WorkflowFilePath
{
get
{
if (this.TryGetValue("workflow_file_path", out var value) && value is StringContextData str)
{
return str.Value;
}
return null;
}
set
{
this["workflow_file_path"] = value != null ? new StringContextData(value) : null;
}
}
}
}
+1 -1
View File
@@ -182,7 +182,7 @@ namespace GitHub.Runner.Worker
_tempDirectoryManager.InitializeTempDirectory(jobContext);
// Setup the debugger
if (jobContext.Global.EnableDebugger)
if (jobContext.Global.Debugger?.Enabled == true)
{
Trace.Info("Debugger enabled for this job run");
+4 -3
View File
@@ -19,10 +19,11 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.1" />
<PackageReference Include="System.Threading.Channels" Version="8.0.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.3" />
<PackageReference Include="System.Threading.Channels" Version="10.0.3" />
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
<PackageReference Include="Microsoft.DevTunnels.Connections" Version="1.3.39" />
</ItemGroup>
<ItemGroup>
+2 -8
View File
@@ -1,4 +1,4 @@
{
{
"definitions": {
"action-root": {
"description": "Action file",
@@ -163,7 +163,6 @@
"output-value": {
"context": [
"github",
"vars",
"strategy",
"matrix",
"steps",
@@ -194,7 +193,6 @@
"context": [
"github",
"inputs",
"vars",
"strategy",
"matrix",
"steps",
@@ -209,7 +207,6 @@
"context": [
"github",
"inputs",
"vars",
"strategy",
"matrix",
"steps",
@@ -224,7 +221,6 @@
"context": [
"github",
"inputs",
"vars",
"strategy",
"matrix",
"steps",
@@ -242,7 +238,6 @@
"context": [
"github",
"inputs",
"vars",
"strategy",
"matrix",
"steps",
@@ -261,7 +256,6 @@
"context": [
"github",
"inputs",
"vars",
"strategy",
"matrix",
"steps",
@@ -276,4 +270,4 @@
}
}
}
}
}
@@ -17,10 +17,9 @@ namespace GitHub.DistributedTask.Expressions2
String expression,
ITraceWriter trace,
IEnumerable<INamedValueInfo> namedValues,
IEnumerable<IFunctionInfo> functions,
Boolean allowCaseFunction = true)
IEnumerable<IFunctionInfo> functions)
{
var context = new ParseContext(expression, trace, namedValues, functions, allowCaseFunction: allowCaseFunction);
var context = new ParseContext(expression, trace, namedValues, functions);
context.Trace.Info($"Parsing expression: <{expression}>");
return CreateTree(context);
}
@@ -416,12 +415,6 @@ namespace GitHub.DistributedTask.Expressions2
String name,
out IFunctionInfo functionInfo)
{
if (String.Equals(name, "case", StringComparison.OrdinalIgnoreCase) && !context.AllowCaseFunction)
{
functionInfo = null;
return false;
}
return ExpressionConstants.WellKnownFunctions.TryGetValue(name, out functionInfo) ||
context.ExtensionFunctions.TryGetValue(name, out functionInfo);
}
@@ -429,7 +422,6 @@ namespace GitHub.DistributedTask.Expressions2
private sealed class ParseContext
{
public Boolean AllowUnknownKeywords;
public Boolean AllowCaseFunction;
public readonly String Expression;
public readonly Dictionary<String, IFunctionInfo> ExtensionFunctions = new Dictionary<String, IFunctionInfo>(StringComparer.OrdinalIgnoreCase);
public readonly Dictionary<String, INamedValueInfo> ExtensionNamedValues = new Dictionary<String, INamedValueInfo>(StringComparer.OrdinalIgnoreCase);
@@ -445,8 +437,7 @@ namespace GitHub.DistributedTask.Expressions2
ITraceWriter trace,
IEnumerable<INamedValueInfo> namedValues,
IEnumerable<IFunctionInfo> functions,
Boolean allowUnknownKeywords = false,
Boolean allowCaseFunction = true)
Boolean allowUnknownKeywords = false)
{
Expression = expression ?? String.Empty;
if (Expression.Length > ExpressionConstants.MaxLength)
@@ -467,7 +458,6 @@ namespace GitHub.DistributedTask.Expressions2
LexicalAnalyzer = new LexicalAnalyzer(Expression);
AllowUnknownKeywords = allowUnknownKeywords;
AllowCaseFunction = allowCaseFunction;
}
private class NoOperationTraceWriter : ITraceWriter
@@ -86,12 +86,6 @@ namespace GitHub.DistributedTask.ObjectTemplating
internal ITraceWriter TraceWriter { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the case expression function is allowed.
/// Defaults to true. Set to false to disable the case function.
/// </summary>
internal Boolean AllowCaseFunction { get; set; } = true;
private IDictionary<String, Int32> FileIds
{
get
@@ -57,7 +57,7 @@ namespace GitHub.DistributedTask.ObjectTemplating.Tokens
var originalBytes = context.Memory.CurrentBytes;
try
{
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
var options = new EvaluationOptions
{
MaxMemory = context.Memory.MaxBytes,
@@ -94,7 +94,7 @@ namespace GitHub.DistributedTask.ObjectTemplating.Tokens
var originalBytes = context.Memory.CurrentBytes;
try
{
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
var options = new EvaluationOptions
{
MaxMemory = context.Memory.MaxBytes,
@@ -123,7 +123,7 @@ namespace GitHub.DistributedTask.ObjectTemplating.Tokens
var originalBytes = context.Memory.CurrentBytes;
try
{
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
var options = new EvaluationOptions
{
MaxMemory = context.Memory.MaxBytes,
@@ -152,7 +152,7 @@ namespace GitHub.DistributedTask.ObjectTemplating.Tokens
var originalBytes = context.Memory.CurrentBytes;
try
{
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
var options = new EvaluationOptions
{
MaxMemory = context.Memory.MaxBytes,
@@ -260,6 +260,28 @@ namespace GitHub.DistributedTask.Pipelines
set;
}
[DataMember(EmitDefaultValue = false)]
public DebuggerTunnelInfo DebuggerTunnel
{
get;
set;
}
/// <summary>
/// Gets the workflow-level action dependencies (lockfile entries)
/// </summary>
public IList<String> ActionsDependencies
{
get
{
if (m_actionsDependencies == null)
{
m_actionsDependencies = new List<String>();
}
return m_actionsDependencies;
}
}
/// <summary>
/// Gets the collection of variables associated with the current context.
/// </summary>
@@ -434,6 +456,11 @@ namespace GitHub.DistributedTask.Pipelines
m_variables = null;
}
if (m_actionsDependencies?.Count == 0)
{
m_actionsDependencies = null;
}
// todo: remove after feature-flag DistributedTask.EvaluateContainerOnRunner is enabled everywhere
if (!string.IsNullOrEmpty(m_jobContainerResourceAlias))
{
@@ -459,6 +486,9 @@ namespace GitHub.DistributedTask.Pipelines
[DataMember(Name = "Variables", EmitDefaultValue = false)]
private IDictionary<String, VariableValue> m_variables;
[DataMember(Name = "dependencies", EmitDefaultValue = false)]
private List<String> m_actionsDependencies;
// todo: remove after feature-flag DistributedTask.EvaluateContainerOnRunner is enabled everywhere
[DataMember(Name = "JobSidecarContainers", EmitDefaultValue = false)]
private IDictionary<String, String> m_jobSidecarContainers;
@@ -0,0 +1,24 @@
using System.Runtime.Serialization;
namespace GitHub.DistributedTask.Pipelines
{
/// <summary>
/// Dev Tunnel information the runner needs to host the debugger tunnel.
/// Matches the run-service <c>DebuggerTunnel</c> contract.
/// </summary>
[DataContract]
public sealed class DebuggerTunnelInfo
{
[DataMember(EmitDefaultValue = false)]
public string TunnelId { get; set; }
[DataMember(EmitDefaultValue = false)]
public string ClusterId { get; set; }
[DataMember(EmitDefaultValue = false)]
public string HostToken { get; set; }
[DataMember(EmitDefaultValue = false)]
public ushort Port { get; set; }
}
}
@@ -681,7 +681,7 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
var node = default(ExpressionNode);
try
{
node = expressionParser.CreateTree(condition, null, namedValues, functions, allowCaseFunction: context.AllowCaseFunction) as ExpressionNode;
node = expressionParser.CreateTree(condition, null, namedValues, functions) as ExpressionNode;
}
catch (Exception ex)
{
@@ -12,5 +12,12 @@ namespace GitHub.DistributedTask.WebApi
get;
set;
}
[DataMember(EmitDefaultValue = false)]
public IList<string> Dependencies
{
get;
set;
}
}
}
+5 -15
View File
@@ -1,4 +1,4 @@
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
using System;
using System.Collections.Generic;
@@ -17,10 +17,9 @@ namespace GitHub.Actions.Expressions
String expression,
ITraceWriter trace,
IEnumerable<INamedValueInfo> namedValues,
IEnumerable<IFunctionInfo> functions,
Boolean allowCaseFunction = true)
IEnumerable<IFunctionInfo> functions)
{
var context = new ParseContext(expression, trace, namedValues, functions, allowCaseFunction: allowCaseFunction);
var context = new ParseContext(expression, trace, namedValues, functions);
context.Trace.Info($"Parsing expression: <{expression}>");
return CreateTree(context);
}
@@ -322,7 +321,7 @@ namespace GitHub.Actions.Expressions
context.Operators.Pop();
}
var functionOperands = PopOperands(context, parameterCount);
// Node already exists on the operand stack
function = (Function)context.Operands.Peek();
@@ -416,12 +415,6 @@ namespace GitHub.Actions.Expressions
String name,
out IFunctionInfo functionInfo)
{
if (String.Equals(name, "case", StringComparison.OrdinalIgnoreCase) && !context.AllowCaseFunction)
{
functionInfo = null;
return false;
}
return ExpressionConstants.WellKnownFunctions.TryGetValue(name, out functionInfo) ||
context.ExtensionFunctions.TryGetValue(name, out functionInfo);
}
@@ -429,7 +422,6 @@ namespace GitHub.Actions.Expressions
private sealed class ParseContext
{
public Boolean AllowUnknownKeywords;
public Boolean AllowCaseFunction;
public readonly String Expression;
public readonly Dictionary<String, IFunctionInfo> ExtensionFunctions = new Dictionary<String, IFunctionInfo>(StringComparer.OrdinalIgnoreCase);
public readonly Dictionary<String, INamedValueInfo> ExtensionNamedValues = new Dictionary<String, INamedValueInfo>(StringComparer.OrdinalIgnoreCase);
@@ -445,8 +437,7 @@ namespace GitHub.Actions.Expressions
ITraceWriter trace,
IEnumerable<INamedValueInfo> namedValues,
IEnumerable<IFunctionInfo> functions,
Boolean allowUnknownKeywords = false,
Boolean allowCaseFunction = true)
Boolean allowUnknownKeywords = false)
{
Expression = expression ?? String.Empty;
if (Expression.Length > ExpressionConstants.MaxLength)
@@ -467,7 +458,6 @@ namespace GitHub.Actions.Expressions
LexicalAnalyzer = new LexicalAnalyzer(Expression);
AllowUnknownKeywords = allowUnknownKeywords;
AllowCaseFunction = allowCaseFunction;
}
private class NoOperationTraceWriter : ITraceWriter
+3 -3
View File
@@ -23,14 +23,14 @@
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="6.0.0" />
<PackageReference Include="System.Security.Cryptography.Cng" Version="5.0.0" />
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.2" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.6" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
<PackageReference Include="Minimatch" Version="2.0.0" />
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
<PackageReference Include="System.Net.Http" Version="4.3.4" />
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
<PackageReference Include="System.Private.Uri" Version="4.3.2" />
<PackageReference Include="System.Formats.Asn1" Version="10.0.2" />
<PackageReference Include="System.Formats.Asn1" Version="10.0.6" />
</ItemGroup>
<ItemGroup>
+3
View File
@@ -22,6 +22,9 @@ namespace GitHub.Services.Launch.Contracts
{
[DataMember(EmitDefaultValue = false, Name = "actions")]
public IList<ActionReferenceRequest> Actions { get; set; }
[DataMember(EmitDefaultValue = false, Name = "actions_dependencies")]
public IList<string> ActionsDependencies { get; set; }
}
[DataContract]
+2 -1
View File
@@ -97,7 +97,8 @@ namespace GitHub.Services.Launch.Client
{
return new ActionReferenceRequestList
{
Actions = actionReferenceList.Actions?.Select(ToGitHubData).ToList()
Actions = actionReferenceList.Actions?.Select(ToGitHubData).ToList(),
ActionsDependencies = actionReferenceList.Dependencies
};
}
@@ -32,7 +32,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
return;
}
var effectiveMax = explicitMax ?? CreatePermissionsFromPolicy(context, permissionsPolicy, includeIdToken: isTrusted, includeModels: context.GetFeatures().AllowModelsPermission);
var effectiveMax = explicitMax ?? CreatePermissionsFromPolicy(context, permissionsPolicy, includeIdToken: isTrusted, includeModels: context.GetFeatures().AllowModelsPermission, includeVulnerabilityAlerts: context.GetFeatures().AllowVulnerabilityAlertsPermission);
if (requested.ViolatesMaxPermissions(effectiveMax, out var permissionLevelViolations))
{
@@ -59,18 +59,19 @@ namespace GitHub.Actions.WorkflowParser.Conversion
TemplateContext context,
string permissionsPolicy,
bool includeIdToken,
bool includeModels)
bool includeModels,
bool includeVulnerabilityAlerts)
{
switch (permissionsPolicy)
{
case WorkflowConstants.PermissionsPolicy.LimitedRead:
return new Permissions(PermissionLevel.NoAccess, includeIdToken: false, includeAttestations: false, includeModels: false)
return new Permissions(PermissionLevel.NoAccess, includeIdToken: false, includeAttestations: false, includeModels: false, includeVulnerabilityAlerts: false)
{
Contents = PermissionLevel.Read,
Packages = PermissionLevel.Read,
};
case WorkflowConstants.PermissionsPolicy.Write:
return new Permissions(PermissionLevel.Write, includeIdToken: includeIdToken, includeAttestations: true, includeModels: includeModels);
return new Permissions(PermissionLevel.Write, includeIdToken: includeIdToken, includeAttestations: true, includeModels: includeModels, includeVulnerabilityAlerts: includeVulnerabilityAlerts);
default:
throw new ArgumentException($"Unexpected permission policy: '{permissionsPolicy}'");
}
@@ -1828,7 +1828,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
var node = default(ExpressionNode);
try
{
node = expressionParser.CreateTree(condition, null, namedValues, functions, allowCaseFunction: context.AllowCaseFunction) as ExpressionNode;
node = expressionParser.CreateTree(condition, null, namedValues, functions) as ExpressionNode;
}
catch (Exception ex)
{
@@ -1877,7 +1877,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
permissionsStr.AssertUnexpectedValue(permissionsStr.Value);
break;
}
return new Permissions(permissionLevel, includeIdToken: true, includeAttestations: true, includeModels: context.GetFeatures().AllowModelsPermission);
return new Permissions(permissionLevel, includeIdToken: true, includeAttestations: true, includeModels: context.GetFeatures().AllowModelsPermission, includeVulnerabilityAlerts: context.GetFeatures().AllowVulnerabilityAlertsPermission);
}
var mapping = token.AssertMapping("permissions");
@@ -1957,6 +1957,23 @@ namespace GitHub.Actions.WorkflowParser.Conversion
context.Error(key, $"The permission 'models' is not allowed");
}
break;
case "vulnerability-alerts":
if (context.GetFeatures().AllowVulnerabilityAlertsPermission)
{
if (permissionLevel == PermissionLevel.Write)
{
permissions.VulnerabilityAlerts = PermissionLevel.Read;
}
else
{
permissions.VulnerabilityAlerts = permissionLevel;
}
}
else
{
context.Error(key, $"The permission 'vulnerability-alerts' is not allowed");
}
break;
default:
break;
}
@@ -1,4 +1,4 @@
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
using System;
using System.Collections.Generic;
@@ -113,12 +113,6 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating
/// </summary>
internal Boolean StrictJsonParsing { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the case expression function is allowed.
/// Defaults to true. Set to false to disable the case function.
/// </summary>
internal Boolean AllowCaseFunction { get; set; } = true;
internal ITraceWriter TraceWriter { get; set; }
private IDictionary<String, Int32> FileIds
@@ -1,4 +1,4 @@
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
using System;
using System.Collections.Generic;
@@ -55,7 +55,7 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens
var originalBytes = context.Memory.CurrentBytes;
try
{
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
var options = new EvaluationOptions
{
MaxMemory = context.Memory.MaxBytes,
@@ -93,7 +93,7 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens
var originalBytes = context.Memory.CurrentBytes;
try
{
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
var options = new EvaluationOptions
{
MaxMemory = context.Memory.MaxBytes,
@@ -123,7 +123,7 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens
var originalBytes = context.Memory.CurrentBytes;
try
{
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
var options = new EvaluationOptions
{
MaxMemory = context.Memory.MaxBytes,
@@ -153,7 +153,7 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens
var originalBytes = context.Memory.CurrentBytes;
try
{
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction);
var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions);
var options = new EvaluationOptions
{
MaxMemory = context.Memory.MaxBytes,
@@ -289,4 +289,4 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens
return result;
}
}
}
}
+22
View File
@@ -32,6 +32,7 @@ namespace GitHub.Actions.WorkflowParser
SecurityEvents = copy.SecurityEvents;
IdToken = copy.IdToken;
Models = copy.Models;
VulnerabilityAlerts = copy.VulnerabilityAlerts;
}
public Permissions(
@@ -61,6 +62,19 @@ namespace GitHub.Actions.WorkflowParser
: PermissionLevel.NoAccess;
}
public Permissions(
PermissionLevel permissionLevel,
bool includeIdToken,
bool includeAttestations,
bool includeModels,
bool includeVulnerabilityAlerts)
: this(permissionLevel, includeIdToken, includeAttestations, includeModels)
{
VulnerabilityAlerts = includeVulnerabilityAlerts
? (permissionLevel == PermissionLevel.Write ? PermissionLevel.Read : permissionLevel)
: PermissionLevel.NoAccess;
}
private static KeyValuePair<string, (PermissionLevel, PermissionLevel)>[] ComparisonKeyMapping(Permissions left, Permissions right)
{
return new[]
@@ -81,6 +95,7 @@ namespace GitHub.Actions.WorkflowParser
new KeyValuePair<string, (PermissionLevel, PermissionLevel)>("security-events", (left.SecurityEvents, right.SecurityEvents)),
new KeyValuePair<string, (PermissionLevel, PermissionLevel)>("id-token", (left.IdToken, right.IdToken)),
new KeyValuePair<string, (PermissionLevel, PermissionLevel)>("models", (left.Models, right.Models)),
new KeyValuePair<string, (PermissionLevel, PermissionLevel)>("vulnerability-alerts", (left.VulnerabilityAlerts, right.VulnerabilityAlerts)),
};
}
@@ -154,6 +169,13 @@ namespace GitHub.Actions.WorkflowParser
set;
}
[DataMember(Name = "vulnerability-alerts", EmitDefaultValue = false)]
public PermissionLevel VulnerabilityAlerts
{
get;
set;
}
[DataMember(Name = "packages", EmitDefaultValue = false)]
public PermissionLevel Packages
{
@@ -41,6 +41,13 @@ namespace GitHub.Actions.WorkflowParser
[DataMember(EmitDefaultValue = false)]
public bool AllowModelsPermission { get; set; }
/// <summary>
/// Gets or sets a value indicating whether users may use the "vulnerability-alerts" permission.
/// Used during parsing only.
/// </summary>
[DataMember(EmitDefaultValue = false)]
public bool AllowVulnerabilityAlertsPermission { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the expression function fromJson performs strict JSON parsing.
/// Used during evaluation only.
@@ -67,6 +74,7 @@ namespace GitHub.Actions.WorkflowParser
Snapshot = false, // Default to false since this feature is still in an experimental phase
StrictJsonParsing = false, // Default to false since this is temporary for telemetry purposes only
AllowModelsPermission = false, // Default to false since we want this to be disabled for all non-production environments
AllowVulnerabilityAlertsPermission = false, // Default to false since we want this to be disabled for all non-production environments
AllowServiceContainerCommand = false, // Default to false since this feature is gated by actions_service_container_command
};
}
+7 -3
View File
@@ -496,8 +496,8 @@
"check-suite-activity": {
"description": "The types of check suite activity that trigger the workflow. Supported activity types: `completed`.",
"one-of": [
"check-suite-activity-type",
"check-suite-activity-types"
"check-suite-activity-type",
"check-suite-activity-types"
]
},
"check-suite-activity-types": {
@@ -1865,11 +1865,15 @@
},
"security-events": {
"type": "permission-level-any",
"description": "Code scanning and Dependabot alerts."
"description": "Code scanning alerts."
},
"statuses": {
"type": "permission-level-any",
"description": "Commit statuses."
},
"vulnerability-alerts": {
"type": "permission-level-read-or-no-access",
"description": "Dependabot alerts."
}
}
}
+7 -25
View File
@@ -1,4 +1,4 @@
using GitHub.DistributedTask.Expressions2;
using GitHub.DistributedTask.Expressions2;
using GitHub.DistributedTask.Expressions2.Sdk;
using GitHub.DistributedTask.ObjectTemplating;
using System;
@@ -9,7 +9,7 @@ namespace GitHub.Runner.Common.Tests.Sdk
{
/// <summary>
/// Regression tests for ExpressionParser.CreateTree to verify that
/// allowCaseFunction does not accidentally set allowUnknownKeywords.
/// the case function does not accidentally set allowUnknownKeywords.
/// </summary>
public sealed class ExpressionParserL0
{
@@ -18,7 +18,7 @@ namespace GitHub.Runner.Common.Tests.Sdk
[Trait("Category", "Sdk")]
public void CreateTree_RejectsUnrecognizedNamedValue()
{
// Regression: allowCaseFunction was passed positionally into
// Regression: the case function parameter was passed positionally into
// the allowUnknownKeywords parameter, causing all named values
// to be silently accepted.
var parser = new ExpressionParser();
@@ -52,7 +52,7 @@ namespace GitHub.Runner.Common.Tests.Sdk
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void CreateTree_CaseFunctionWorks_WhenAllowed()
public void CreateTree_CaseFunctionWorks()
{
var parser = new ExpressionParser();
var namedValues = new List<INamedValueInfo>
@@ -60,35 +60,17 @@ namespace GitHub.Runner.Common.Tests.Sdk
new NamedValueInfo<ContextValueNode>("github"),
};
var node = parser.CreateTree("case(github.event_name, 'push', 'Push Event')", null, namedValues, null, allowCaseFunction: true);
var node = parser.CreateTree("case(github.event_name, 'push', 'Push Event')", null, namedValues, null);
Assert.NotNull(node);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void CreateTree_CaseFunctionRejected_WhenDisallowed()
{
var parser = new ExpressionParser();
var namedValues = new List<INamedValueInfo>
{
new NamedValueInfo<ContextValueNode>("github"),
};
var ex = Assert.Throws<ParseException>(() =>
parser.CreateTree("case(github.event_name, 'push', 'Push Event')", null, namedValues, null, allowCaseFunction: false));
Assert.Contains("Unrecognized function", ex.Message);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Sdk")]
public void CreateTree_CaseFunctionDoesNotAffectUnknownKeywords()
{
// The key regression test: with allowCaseFunction=true (default),
// unrecognized named values must still be rejected.
// The key regression test: unrecognized named values must still be rejected.
var parser = new ExpressionParser();
var namedValues = new List<INamedValueInfo>
{
@@ -96,7 +78,7 @@ namespace GitHub.Runner.Common.Tests.Sdk
};
var ex = Assert.Throws<ParseException>(() =>
parser.CreateTree("github.ref", null, namedValues, null, allowCaseFunction: true));
parser.CreateTree("github.ref", null, namedValues, null));
Assert.Contains("Unrecognized named-value", ex.Message);
}
@@ -69,6 +69,98 @@ public sealed class AgentJobRequestMessageL0
Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should be false when JSON contains 'EnableDebugger': false");
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Common")]
public void VerifyDebuggerTunnelDeserialization_WithTunnel()
{
// Arrange
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage), new DataContractJsonSerializerSettings
{
KnownTypes = new[] { typeof(DebuggerTunnelInfo) }
});
string json = DoubleQuotify(
"{'EnableDebugger': true, 'DebuggerTunnel': {'TunnelId': 'tun-123', 'ClusterId': 'use2', 'HostToken': 'tok-abc', 'Port': 4711}}");
// Act
using var stream = new MemoryStream();
stream.Write(Encoding.UTF8.GetBytes(json));
stream.Position = 0;
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
// Assert
Assert.NotNull(recoveredMessage);
Assert.True(recoveredMessage.EnableDebugger);
Assert.NotNull(recoveredMessage.DebuggerTunnel);
Assert.Equal("tun-123", recoveredMessage.DebuggerTunnel.TunnelId);
Assert.Equal("use2", recoveredMessage.DebuggerTunnel.ClusterId);
Assert.Equal("tok-abc", recoveredMessage.DebuggerTunnel.HostToken);
Assert.Equal(4711, recoveredMessage.DebuggerTunnel.Port);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Common")]
public void VerifyDebuggerTunnelDeserialization_WithoutTunnel()
{
// Arrange
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
string json = DoubleQuotify("{'EnableDebugger': true}");
// Act
using var stream = new MemoryStream();
stream.Write(Encoding.UTF8.GetBytes(json));
stream.Position = 0;
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
// Assert
Assert.NotNull(recoveredMessage);
Assert.True(recoveredMessage.EnableDebugger);
Assert.Null(recoveredMessage.DebuggerTunnel);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Common")]
public void VerifyActionsDependenciesDeserialization_WithDependencies()
{
// Arrange
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
string json = DoubleQuotify("{'dependencies': ['actions/checkout@v4:sha256-abc123', 'actions/setup-node@v4:sha256-def456']}");
// Act
using var stream = new MemoryStream();
stream.Write(Encoding.UTF8.GetBytes(json));
stream.Position = 0;
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
// Assert
Assert.NotNull(recoveredMessage);
Assert.Equal(2, recoveredMessage.ActionsDependencies.Count);
Assert.Equal("actions/checkout@v4:sha256-abc123", recoveredMessage.ActionsDependencies[0]);
Assert.Equal("actions/setup-node@v4:sha256-def456", recoveredMessage.ActionsDependencies[1]);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Common")]
public void VerifyActionsDependenciesDeserialization_DefaultsToEmpty()
{
// Arrange
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
string json = DoubleQuotify("{'messageType': 'PipelineAgentJobRequest'}");
// Act
using var stream = new MemoryStream();
stream.Write(Encoding.UTF8.GetBytes(json));
stream.Position = 0;
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
// Assert
Assert.NotNull(recoveredMessage);
Assert.Empty(recoveredMessage.ActionsDependencies);
}
private static string DoubleQuotify(string text)
{
return text.Replace('\'', '"');
+790
View File
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.IO;
using System.IO.Compression;
using System.Net;
@@ -1254,6 +1255,659 @@ runs:
}
#endif
// =================================================================
// Tests for batched action resolution optimization
// =================================================================
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async void PrepareActions_BatchesResolutionAcrossCompositeActions()
{
// Verifies that when multiple composite actions at the same depth
// reference sub-actions, those sub-actions are resolved in a single
// batched API call rather than one call per composite.
//
// Action tree:
// CompositePrestep (composite) → [Node action, CompositePrestep2 (composite)]
// CompositePrestep2 (composite) → [Node action, Docker action]
//
// Without batching: 3 API calls (depth 0, depth 1 for CompositePrestep, depth 2 for CompositePrestep2)
// With batching: still 3 calls at most, but the key is that depth-1
// sub-actions from all composites at depth 0 are batched into 1 call.
// And the same action appearing at multiple depths triggers only 1 resolve.
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
try
{
//Arrange
Setup();
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
var resolveCallCount = 0;
var resolvedActions = new List<ActionReferenceList>();
_jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>()))
.Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) =>
{
resolveCallCount++;
resolvedActions.Add(actions);
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
foreach (var action in actions.Actions)
{
var key = $"{action.NameWithOwner}@{action.Ref}";
result.Actions[key] = new ActionDownloadInfo
{
NameWithOwner = action.NameWithOwner,
Ref = action.Ref,
ResolvedNameWithOwner = action.NameWithOwner,
ResolvedSha = $"{action.Ref}-sha",
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
};
}
return Task.FromResult(result);
});
var actionId = Guid.NewGuid();
var actions = new List<Pipelines.ActionStep>
{
new Pipelines.ActionStep()
{
Name = "action",
Id = actionId,
Reference = new Pipelines.RepositoryPathReference()
{
Name = "TingluoHuang/runner_L0",
Ref = "CompositePrestep",
RepositoryType = "GitHub"
}
}
};
//Act
var result = await _actionManager.PrepareActionsAsync(_ec.Object, actions);
//Assert
// The composite tree is:
// depth 0: CompositePrestep
// depth 1: Node@RepositoryActionWithWrapperActionfile_Node + CompositePrestep2
// depth 2: Node@RepositoryActionWithWrapperActionfile_Node + Docker@RepositoryActionWithWrapperActionfile_Docker
//
// With batching:
// Call 1 (depth 0, resolve): CompositePrestep
// Call 2 (depth 0→1, pre-resolve): Node + CompositePrestep2 in one batch
// Call 3 (depth 1→2, pre-resolve): Docker only (Node already cached from call 2)
Assert.Equal(3, resolveCallCount);
// Call 1: depth 0 resolve — just the top-level composite
var call1Keys = resolvedActions[0].Actions.Select(a => $"{a.NameWithOwner}@{a.Ref}").OrderBy(k => k).ToList();
Assert.Equal(new[] { "TingluoHuang/runner_L0@CompositePrestep" }, call1Keys);
// Call 2: depth 0→1 pre-resolve — batch both children of CompositePrestep
var call2Keys = resolvedActions[1].Actions.Select(a => $"{a.NameWithOwner}@{a.Ref}").OrderBy(k => k).ToList();
Assert.Equal(new[] { "TingluoHuang/runner_L0@CompositePrestep2", "TingluoHuang/runner_L0@RepositoryActionWithWrapperActionfile_Node" }, call2Keys);
// Call 3: depth 1→2 pre-resolve — only Docker (Node was cached in call 2)
var call3Keys = resolvedActions[2].Actions.Select(a => $"{a.NameWithOwner}@{a.Ref}").OrderBy(k => k).ToList();
Assert.Equal(new[] { "TingluoHuang/runner_L0@RepositoryActionWithWrapperActionfile_Docker" }, call3Keys);
// Verify all actions were downloaded
Assert.True(File.Exists(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "CompositePrestep.completed")));
Assert.True(File.Exists(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Node.completed")));
Assert.True(File.Exists(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "CompositePrestep2.completed")));
Assert.True(File.Exists(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Docker.completed")));
// Verify pre-step tracking still works correctly
Assert.Equal(1, result.PreStepTracker.Count);
}
finally
{
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async void PrepareActions_DeduplicatesResolutionAcrossDepthLevels()
{
// Verifies that an action appearing at multiple depths in the
// composite tree is only resolved once (not re-resolved at each level).
//
// CompositePrestep uses Node action at depth 1.
// CompositePrestep2 (also at depth 1) uses the SAME Node action at depth 2.
// The Node action should only be resolved once total.
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
try
{
//Arrange
Setup();
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
var allResolvedKeys = new List<string>();
_jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>()))
.Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) =>
{
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
foreach (var action in actions.Actions)
{
var key = $"{action.NameWithOwner}@{action.Ref}";
allResolvedKeys.Add(key);
result.Actions[key] = new ActionDownloadInfo
{
NameWithOwner = action.NameWithOwner,
Ref = action.Ref,
ResolvedNameWithOwner = action.NameWithOwner,
ResolvedSha = $"{action.Ref}-sha",
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
};
}
return Task.FromResult(result);
});
var actionId = Guid.NewGuid();
var actions = new List<Pipelines.ActionStep>
{
new Pipelines.ActionStep()
{
Name = "action",
Id = actionId,
Reference = new Pipelines.RepositoryPathReference()
{
Name = "TingluoHuang/runner_L0",
Ref = "CompositePrestep",
RepositoryType = "GitHub"
}
}
};
//Act
await _actionManager.PrepareActionsAsync(_ec.Object, actions);
//Assert
// TingluoHuang/runner_L0@RepositoryActionWithWrapperActionfile_Node appears
// at both depth 1 (sub-step of CompositePrestep) and depth 2 (sub-step of
// CompositePrestep2). With deduplication it should only be resolved once.
var nodeActionKey = "TingluoHuang/runner_L0@RepositoryActionWithWrapperActionfile_Node";
var nodeResolveCount = allResolvedKeys.FindAll(k => k == nodeActionKey).Count;
Assert.Equal(1, nodeResolveCount);
// Verify the total number of unique actions resolved matches the tree
var uniqueKeys = new HashSet<string>(allResolvedKeys);
// Expected unique actions: CompositePrestep, Node, CompositePrestep2, Docker = 4
Assert.Equal(4, uniqueKeys.Count);
}
finally
{
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async void PrepareActions_MultipleTopLevelActions_BatchesResolution()
{
// Verifies that multiple independent actions at depth 0 are
// resolved in a single API call.
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
try
{
//Arrange
Setup();
// Node action has pre+post, needs IActionRunner instances
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
var resolveCallCount = 0;
var firstCallActionCount = 0;
_jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>()))
.Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) =>
{
resolveCallCount++;
if (resolveCallCount == 1)
{
firstCallActionCount = actions.Actions.Count;
}
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
foreach (var action in actions.Actions)
{
var key = $"{action.NameWithOwner}@{action.Ref}";
result.Actions[key] = new ActionDownloadInfo
{
NameWithOwner = action.NameWithOwner,
Ref = action.Ref,
ResolvedNameWithOwner = action.NameWithOwner,
ResolvedSha = $"{action.Ref}-sha",
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
};
}
return Task.FromResult(result);
});
var actions = new List<Pipelines.ActionStep>
{
new Pipelines.ActionStep()
{
Name = "action1",
Id = Guid.NewGuid(),
Reference = new Pipelines.RepositoryPathReference()
{
Name = "TingluoHuang/runner_L0",
Ref = "RepositoryActionWithWrapperActionfile_Node",
RepositoryType = "GitHub"
}
},
new Pipelines.ActionStep()
{
Name = "action2",
Id = Guid.NewGuid(),
Reference = new Pipelines.RepositoryPathReference()
{
Name = "TingluoHuang/runner_L0",
Ref = "RepositoryActionWithWrapperActionfile_Docker",
RepositoryType = "GitHub"
}
}
};
//Act
await _actionManager.PrepareActionsAsync(_ec.Object, actions);
//Assert
// Both actions are at depth 0 — should be resolved in a single batch call
Assert.Equal(1, resolveCallCount);
Assert.Equal(2, firstCallActionCount);
// Verify both were downloaded
Assert.True(File.Exists(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Node.completed")));
Assert.True(File.Exists(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Docker.completed")));
}
finally
{
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
Teardown();
}
}
#if OS_LINUX
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async void PrepareActions_NestedCompositeContainers_BatchedResolution()
{
// Verifies batching with nested composite actions that reference
// container actions (Linux-only since containers require Linux).
//
// CompositeContainerNested (composite):
// → repositoryactionwithdockerfile (Dockerfile)
// → CompositeContainerNested2 (composite):
// → repositoryactionwithdockerfile (Dockerfile, same as above)
// → notpullorbuildimagesmultipletimes1 (Dockerfile)
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
try
{
//Arrange
Setup();
var resolveCallCount = 0;
_jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>()))
.Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) =>
{
resolveCallCount++;
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
foreach (var action in actions.Actions)
{
var key = $"{action.NameWithOwner}@{action.Ref}";
result.Actions[key] = new ActionDownloadInfo
{
NameWithOwner = action.NameWithOwner,
Ref = action.Ref,
ResolvedNameWithOwner = action.NameWithOwner,
ResolvedSha = $"{action.Ref}-sha",
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
};
}
return Task.FromResult(result);
});
var actionId = Guid.NewGuid();
var actions = new List<Pipelines.ActionStep>
{
new Pipelines.ActionStep()
{
Name = "action",
Id = actionId,
Reference = new Pipelines.RepositoryPathReference()
{
Name = "TingluoHuang/runner_L0",
Ref = "CompositeContainerNested",
RepositoryType = "GitHub"
}
}
};
//Act
var result = await _actionManager.PrepareActionsAsync(_ec.Object, actions);
//Assert
// Tree has 3 depth levels with 5 unique actions.
// With batching, should need at most 3 resolve calls (one per depth level).
Assert.True(resolveCallCount <= 3, $"Expected at most 3 resolve calls but got {resolveCallCount}");
// repositoryactionwithdockerfile appears at both depth 1 and depth 2.
// Container setup should still work correctly — 2 unique Docker images.
Assert.Equal(2, result.ContainerSetupSteps.Count);
}
finally
{
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
Teardown();
}
}
#endif
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async void PrepareActions_ParallelDownloads_MultipleUniqueActions()
{
// Verifies that multiple unique top-level actions are downloaded via
// DownloadActionsInParallelAsync (the parallel code path), and that
// all actions are correctly resolved and downloaded.
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
try
{
//Arrange
Setup();
// Node action has pre step, and CompositePrestep recurses into
// sub-actions that also need IActionRunner instances
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
var resolveCallCount = 0;
_jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>()))
.Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) =>
{
Interlocked.Increment(ref resolveCallCount);
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
foreach (var action in actions.Actions)
{
var key = $"{action.NameWithOwner}@{action.Ref}";
result.Actions[key] = new ActionDownloadInfo
{
NameWithOwner = action.NameWithOwner,
Ref = action.Ref,
ResolvedNameWithOwner = action.NameWithOwner,
ResolvedSha = $"{action.Ref}-sha",
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
};
}
return Task.FromResult(result);
});
var actions = new List<Pipelines.ActionStep>
{
new Pipelines.ActionStep()
{
Name = "action1",
Id = Guid.NewGuid(),
Reference = new Pipelines.RepositoryPathReference()
{
Name = "TingluoHuang/runner_L0",
Ref = "RepositoryActionWithWrapperActionfile_Node",
RepositoryType = "GitHub"
}
},
new Pipelines.ActionStep()
{
Name = "action2",
Id = Guid.NewGuid(),
Reference = new Pipelines.RepositoryPathReference()
{
Name = "TingluoHuang/runner_L0",
Ref = "RepositoryActionWithWrapperActionfile_Docker",
RepositoryType = "GitHub"
}
},
new Pipelines.ActionStep()
{
Name = "action3",
Id = Guid.NewGuid(),
Reference = new Pipelines.RepositoryPathReference()
{
Name = "TingluoHuang/runner_L0",
Ref = "CompositePrestep",
RepositoryType = "GitHub"
}
}
};
//Act
await _actionManager.PrepareActionsAsync(_ec.Object, actions);
//Assert
// 3 unique actions at depth 0 → triggers DownloadActionsInParallelAsync
// (parallel path used when uniqueDownloads.Count > 1)
var nodeCompleted = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Node.completed");
var dockerCompleted = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Docker.completed");
var compositeCompleted = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "CompositePrestep.completed");
Assert.True(File.Exists(nodeCompleted), $"Expected watermark at {nodeCompleted}");
Assert.True(File.Exists(dockerCompleted), $"Expected watermark at {dockerCompleted}");
Assert.True(File.Exists(compositeCompleted), $"Expected watermark at {compositeCompleted}");
// All depth-0 actions resolved in a single batch call.
// Composite sub-actions may add 1-2 more calls.
Assert.True(resolveCallCount >= 1, "Expected at least 1 resolve call");
}
finally
{
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async void PrepareActions_DownloadsNextLevelActionsBeforeRecursing()
{
// Verifies that depth-1 actions are downloaded before the depth-2
// pre-resolve fires. We detect this by snapshotting watermark state
// inside the 3rd ResolveActionDownloadInfoAsync callback (which is
// the depth-2 pre-resolve). If pre-download works, depth-1 watermarks
// already exist at that point.
//
// Action tree:
// CompositePrestep (composite) → [Node, CompositePrestep2 (composite)]
// CompositePrestep2 (composite) → [Node, Docker]
//
// Without pre-download: downloads happen during recursion (serial per depth)
// With pre-download: depth 1 actions (Node + CompositePrestep2) are
// downloaded in parallel before recursing, so recursion is a no-op
// for downloads.
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
try
{
//Arrange
Setup();
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
// Track watermark state at the time of each resolve call.
// If pre-download works, when the 3rd resolve fires (depth 2
// pre-resolve for Docker), the depth-1 actions (Node +
// CompositePrestep2) should already have watermarks on disk.
var resolveCallCount = 0;
var watermarksAtResolve3 = new Dictionary<string, bool>();
_jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>()))
.Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) =>
{
resolveCallCount++;
if (resolveCallCount == 3)
{
// At the time of the 3rd resolve, check if depth-1 actions
// are already downloaded (pre-download should have done this)
var actionsDir2 = _hc.GetDirectory(WellKnownDirectory.Actions);
watermarksAtResolve3["Node"] = File.Exists(Path.Combine(actionsDir2, "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Node.completed"));
watermarksAtResolve3["CompositePrestep2"] = File.Exists(Path.Combine(actionsDir2, "TingluoHuang/runner_L0", "CompositePrestep2.completed"));
}
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
foreach (var action in actions.Actions)
{
var key = $"{action.NameWithOwner}@{action.Ref}";
result.Actions[key] = new ActionDownloadInfo
{
NameWithOwner = action.NameWithOwner,
Ref = action.Ref,
ResolvedNameWithOwner = action.NameWithOwner,
ResolvedSha = $"{action.Ref}-sha",
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
};
}
return Task.FromResult(result);
});
var actionId = Guid.NewGuid();
var actions = new List<Pipelines.ActionStep>
{
new Pipelines.ActionStep()
{
Name = "action",
Id = actionId,
Reference = new Pipelines.RepositoryPathReference()
{
Name = "TingluoHuang/runner_L0",
Ref = "CompositePrestep",
RepositoryType = "GitHub"
}
}
};
//Act
var result = await _actionManager.PrepareActionsAsync(_ec.Object, actions);
//Assert
// All actions should be downloaded (watermarks exist)
var actionsDir = _hc.GetDirectory(WellKnownDirectory.Actions);
Assert.True(File.Exists(Path.Combine(actionsDir, "TingluoHuang/runner_L0", "CompositePrestep.completed")));
Assert.True(File.Exists(Path.Combine(actionsDir, "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Node.completed")));
Assert.True(File.Exists(Path.Combine(actionsDir, "TingluoHuang/runner_L0", "CompositePrestep2.completed")));
Assert.True(File.Exists(Path.Combine(actionsDir, "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Docker.completed")));
// 3 resolve calls total
Assert.Equal(3, resolveCallCount);
// The key assertion: at the time of the 3rd resolve call
// (pre-resolve for depth 2), the depth-1 actions should
// ALREADY be downloaded thanks to pre-download.
// Without pre-download, these watermarks wouldn't exist yet
// because depth-1 downloads would only happen during recursion.
Assert.True(watermarksAtResolve3["Node"],
"Node action should be pre-downloaded before depth 2 pre-resolve");
Assert.True(watermarksAtResolve3["CompositePrestep2"],
"CompositePrestep2 should be pre-downloaded before depth 2 pre-resolve");
}
finally
{
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async void PrepareActions_ParallelDownloadsAtSameDepth()
{
// Verifies that multiple unique actions at the same depth are
// downloaded concurrently (Task.WhenAll) rather than sequentially.
// We detect this by checking that all watermarks exist after a
// single PrepareActionsAsync call with multiple top-level actions.
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
try
{
//Arrange
Setup();
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
_hc.EnqueueInstance<IActionRunner>(new Mock<IActionRunner>().Object);
_jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>()))
.Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) =>
{
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
foreach (var action in actions.Actions)
{
var key = $"{action.NameWithOwner}@{action.Ref}";
result.Actions[key] = new ActionDownloadInfo
{
NameWithOwner = action.NameWithOwner,
Ref = action.Ref,
ResolvedNameWithOwner = action.NameWithOwner,
ResolvedSha = $"{action.Ref}-sha",
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
};
}
return Task.FromResult(result);
});
var actions = new List<Pipelines.ActionStep>
{
new Pipelines.ActionStep()
{
Name = "action1",
Id = Guid.NewGuid(),
Reference = new Pipelines.RepositoryPathReference()
{
Name = "TingluoHuang/runner_L0",
Ref = "RepositoryActionWithWrapperActionfile_Node",
RepositoryType = "GitHub"
}
},
new Pipelines.ActionStep()
{
Name = "action2",
Id = Guid.NewGuid(),
Reference = new Pipelines.RepositoryPathReference()
{
Name = "TingluoHuang/runner_L0",
Ref = "RepositoryActionWithWrapperActionfile_Docker",
RepositoryType = "GitHub"
}
}
};
//Act
await _actionManager.PrepareActionsAsync(_ec.Object, actions);
//Assert - both downloaded (parallel path used when > 1 unique download)
var actionsDir = _hc.GetDirectory(WellKnownDirectory.Actions);
Assert.True(File.Exists(Path.Combine(actionsDir, "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Node.completed")));
Assert.True(File.Exists(Path.Combine(actionsDir, "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Docker.completed")));
}
finally
{
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
@@ -2629,5 +3283,141 @@ runs:
Directory.Delete(_workFolder, recursive: true);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task GetDownloadInfoAsync_PropagatesDependencies_WhenPresent()
{
try
{
// Arrange
Setup();
// Set RunServiceJob so we hit the Launch path
_ec.Object.Global.Variables.Set(Constants.Variables.System.JobRequestType, "RunnerJobRequest");
// Populate lockfile dependencies
_ec.Object.Global.ActionsDependencies = new List<string>
{
"github.com/actions/checkout@v4:sha256-abc123",
"github.com/actions/setup-node@v4:sha256-def456"
};
// Capture the ActionReferenceList passed to Launch
ActionReferenceList capturedList = null;
_launchServer
.Setup(x => x.ResolveActionsDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>(), It.IsAny<bool>()))
.Callback<Guid, Guid, ActionReferenceList, CancellationToken, bool>((planId, jobId, list, ct, display) => capturedList = list)
.Returns((Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken ct, bool display) =>
{
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
foreach (var action in actions.Actions)
{
var key = $"{action.NameWithOwner}@{action.Ref}";
result.Actions[key] = new ActionDownloadInfo
{
NameWithOwner = action.NameWithOwner,
Ref = action.Ref,
ResolvedNameWithOwner = action.NameWithOwner,
ResolvedSha = $"{action.Ref}-sha",
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
};
}
return Task.FromResult(result);
});
var actionStep = new Pipelines.ActionStep()
{
Name = "action",
Id = Guid.NewGuid(),
Reference = new Pipelines.RepositoryPathReference()
{
Name = "actions/checkout",
Ref = "v4",
RepositoryType = "GitHub"
}
};
// Act
var result = await _actionManager.PrepareActionsAsync(_ec.Object, new List<Pipelines.JobStep> { actionStep }, default);
// Assert
Assert.NotNull(capturedList);
Assert.NotNull(capturedList.Dependencies);
Assert.Equal(2, capturedList.Dependencies.Count);
Assert.Equal("github.com/actions/checkout@v4:sha256-abc123", capturedList.Dependencies[0]);
Assert.Equal("github.com/actions/setup-node@v4:sha256-def456", capturedList.Dependencies[1]);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task GetDownloadInfoAsync_OmitsDependencies_WhenEmpty()
{
try
{
// Arrange
Setup();
// Set RunServiceJob so we hit the Launch path
_ec.Object.Global.Variables.Set(Constants.Variables.System.JobRequestType, "RunnerJobRequest");
// No dependencies set (default empty list from GlobalContext)
// Capture the ActionReferenceList passed to Launch
ActionReferenceList capturedList = null;
_launchServer
.Setup(x => x.ResolveActionsDownloadInfoAsync(It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>(), It.IsAny<bool>()))
.Callback<Guid, Guid, ActionReferenceList, CancellationToken, bool>((planId, jobId, list, ct, display) => capturedList = list)
.Returns((Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken ct, bool display) =>
{
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
foreach (var action in actions.Actions)
{
var key = $"{action.NameWithOwner}@{action.Ref}";
result.Actions[key] = new ActionDownloadInfo
{
NameWithOwner = action.NameWithOwner,
Ref = action.Ref,
ResolvedNameWithOwner = action.NameWithOwner,
ResolvedSha = $"{action.Ref}-sha",
TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}",
ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}",
};
}
return Task.FromResult(result);
});
var actionStep = new Pipelines.ActionStep()
{
Name = "action",
Id = Guid.NewGuid(),
Reference = new Pipelines.RepositoryPathReference()
{
Name = "actions/checkout",
Ref = "v4",
RepositoryType = "GitHub"
}
};
// Act
var result = await _actionManager.PrepareActionsAsync(_ec.Object, new List<Pipelines.JobStep> { actionStep }, default);
// Assert
Assert.NotNull(capturedList);
Assert.Null(capturedList.Dependencies);
}
finally
{
Teardown();
}
}
}
}
+40 -1
View File
@@ -504,7 +504,7 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[Fact]
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Load_Node24Action()
@@ -1006,6 +1006,45 @@ namespace GitHub.Runner.Common.Tests.Worker
_ec.Setup(x => x.AddIssue(It.IsAny<Issue>(), It.IsAny<ExecutionContextLogOptions>())).Callback((Issue issue, ExecutionContextLogOptions logOptions) => { _hc.GetTrace().Info($"[{issue.Type}]{logOptions.LogMessageOverride ?? issue.Message}"); });
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Evaluate_Default_Input_Case_Function()
{
try
{
//Arrange
Setup();
var actionManifest = new ActionManifestManager();
actionManifest.Initialize(_hc);
_ec.Object.ExpressionValues["github"] = new LegacyContextData.DictionaryContextData
{
{ "ref", new LegacyContextData.StringContextData("refs/heads/main") },
};
_ec.Object.ExpressionValues["strategy"] = new LegacyContextData.DictionaryContextData();
_ec.Object.ExpressionValues["matrix"] = new LegacyContextData.DictionaryContextData();
_ec.Object.ExpressionValues["steps"] = new LegacyContextData.DictionaryContextData();
_ec.Object.ExpressionValues["job"] = new LegacyContextData.DictionaryContextData();
_ec.Object.ExpressionValues["runner"] = new LegacyContextData.DictionaryContextData();
_ec.Object.ExpressionValues["env"] = new LegacyContextData.DictionaryContextData();
_ec.Object.ExpressionFunctions.Add(new LegacyExpressions.FunctionInfo<GitHub.Runner.Worker.Expressions.HashFilesFunction>("hashFiles", 1, 255));
// Act — evaluate a case() expression as a default input value.
// The feature flag is set, so this should succeed.
var token = new BasicExpressionToken(null, null, null, "case(true, 'matched', 'default')");
var result = actionManifest.EvaluateDefaultInput(_ec.Object, "testInput", token);
// Assert — case() should evaluate successfully
Assert.Equal("matched", result);
}
finally
{
Teardown();
}
}
private void Teardown()
{
_hc?.Dispose();
+404 -207
View File
@@ -1,7 +1,8 @@
using System;
using System;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
@@ -16,15 +17,48 @@ namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class DapDebuggerL0
{
private const string PortEnvironmentVariable = "ACTIONS_RUNNER_DAP_PORT";
private const string TimeoutEnvironmentVariable = "ACTIONS_RUNNER_DAP_CONNECTION_TIMEOUT";
private const string TunnelConnectTimeoutVariable = "ACTIONS_RUNNER_DAP_TUNNEL_CONNECT_TIMEOUT_SECONDS";
private DapDebugger _debugger;
private TestWebSocketDapBridge _testWebSocketBridge;
private TestHostContext CreateTestContext([CallerMemberName] string testName = "")
private sealed class TestWebSocketDapBridge : RunnerService, IWebSocketDapBridge
{
private readonly WebSocketDapBridge _inner = new WebSocketDapBridge();
public int ListenPort => _inner.ListenPort;
public override void Initialize(IHostContext hostContext)
{
base.Initialize(hostContext);
_inner.Initialize(hostContext);
}
public void Start(int listenPort, int targetPort)
{
_inner.Start(0, targetPort);
}
public Task ShutdownAsync()
{
return _inner.ShutdownAsync();
}
}
private TestHostContext CreateTestContext(bool enableWebSocketBridge = false, [CallerMemberName] string testName = "")
{
var hc = new TestHostContext(this, testName);
_debugger = new DapDebugger();
_testWebSocketBridge = null;
_debugger.Initialize(hc);
_debugger.SkipTunnelRelay = true;
_debugger.SkipWebSocketBridge = !enableWebSocketBridge;
if (enableWebSocketBridge)
{
_testWebSocketBridge = new TestWebSocketDapBridge();
hc.EnqueueInstance<IWebSocketDapBridge>(_testWebSocketBridge);
}
return hc;
}
@@ -56,11 +90,11 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
private static int GetFreePort()
private static ushort GetFreePort()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
return ((IPEndPoint)listener.LocalEndpoint).Port;
return (ushort)((IPEndPoint)listener.LocalEndpoint).Port;
}
private static async Task<TcpClient> ConnectClientAsync(int port)
@@ -70,6 +104,14 @@ namespace GitHub.Runner.Common.Tests.Worker
return client;
}
private static async Task<ClientWebSocket> ConnectWebSocketClientAsync(int port)
{
var client = new ClientWebSocket();
client.Options.Proxy = null;
await client.ConnectAsync(new Uri($"ws://127.0.0.1:{port}/"), CancellationToken.None);
return client;
}
private static async Task SendRequestAsync(NetworkStream stream, Request request)
{
var json = JsonConvert.SerializeObject(request);
@@ -82,6 +124,14 @@ namespace GitHub.Runner.Common.Tests.Worker
await stream.FlushAsync();
}
private static async Task SendRequestAsync(WebSocket client, Request request)
{
var json = JsonConvert.SerializeObject(request);
var body = Encoding.UTF8.GetBytes(json);
await client.SendAsync(new ArraySegment<byte>(body), WebSocketMessageType.Text, endOfMessage: true, CancellationToken.None);
}
/// <summary>
/// Reads a single DAP-framed message from a stream with a timeout.
/// Parses the Content-Length header, reads exactly that many bytes,
@@ -140,10 +190,65 @@ namespace GitHub.Runner.Common.Tests.Worker
return Encoding.UTF8.GetString(body);
}
private static Mock<IExecutionContext> CreateJobContext(CancellationToken cancellationToken, string jobName = null)
private static async Task<string> ReadWebSocketDataUntilAsync(WebSocket client, TimeSpan timeout, params string[] expectedFragments)
{
using var cts = new CancellationTokenSource(timeout);
var buffer = new byte[4096];
var allMessages = new StringBuilder();
while (true)
{
using var messageStream = new MemoryStream();
WebSocketReceiveResult result;
do
{
result = await client.ReceiveAsync(new ArraySegment<byte>(buffer), cts.Token);
if (result.MessageType == WebSocketMessageType.Close)
{
throw new EndOfStreamException("WebSocket closed before expected DAP messages were received.");
}
if (result.Count > 0)
{
messageStream.Write(buffer, 0, result.Count);
}
}
while (!result.EndOfMessage);
var messageText = Encoding.UTF8.GetString(messageStream.ToArray());
allMessages.Append(messageText);
var text = allMessages.ToString();
var containsAllFragments = true;
foreach (var fragment in expectedFragments)
{
if (!text.Contains(fragment, StringComparison.Ordinal))
{
containsAllFragments = false;
break;
}
}
if (containsAllFragments)
{
return text;
}
}
}
private static Mock<IExecutionContext> CreateJobContextWithTunnel(CancellationToken cancellationToken, ushort port, string jobName = null)
{
var tunnel = new GitHub.DistributedTask.Pipelines.DebuggerTunnelInfo
{
TunnelId = "test-tunnel",
ClusterId = "test-cluster",
HostToken = "test-token",
Port = port
};
var debuggerConfig = new DebuggerConfig(true, tunnel);
var jobContext = new Mock<IExecutionContext>();
jobContext.Setup(x => x.CancellationToken).Returns(cancellationToken);
jobContext.Setup(x => x.Global).Returns(new GlobalContext { Debugger = debuggerConfig });
jobContext
.Setup(x => x.GetGitHubContext(It.IsAny<string>()))
.Returns((string contextName) => string.Equals(contextName, "job", StringComparison.Ordinal) ? jobName : null);
@@ -165,42 +270,114 @@ namespace GitHub.Runner.Common.Tests.Worker
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ResolvePortUsesCustomPortFromEnvironment()
public async Task StartAsyncFailsWithoutValidTunnelConfig()
{
using (CreateTestContext())
{
WithEnvironmentVariable(PortEnvironmentVariable, "9999", () =>
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = new Mock<IExecutionContext>();
jobContext.Setup(x => x.CancellationToken).Returns(cts.Token);
jobContext.Setup(x => x.Global).Returns(new GlobalContext
{
Assert.Equal(9999, _debugger.ResolvePort());
Debugger = new DebuggerConfig(true, null)
});
await Assert.ThrowsAsync<ArgumentException>(() => _debugger.StartAsync(jobContext.Object));
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ResolvePortIgnoresInvalidPortFromEnvironment()
public async Task StartAsyncUsesPortFromTunnelConfig()
{
using (CreateTestContext())
{
WithEnvironmentVariable(PortEnvironmentVariable, "not-a-number", () =>
{
Assert.Equal(4711, _debugger.ResolvePort());
});
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
Assert.True(client.Connected);
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ResolvePortIgnoresOutOfRangePortFromEnvironment()
public async Task StartAsyncWithWebSocketBridgeAcceptsInitializeOverWebSocket()
{
using (CreateTestContext())
using (CreateTestContext(enableWebSocketBridge: true))
{
WithEnvironmentVariable(PortEnvironmentVariable, "99999", () =>
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, GetFreePort());
await _debugger.StartAsync(jobContext.Object);
var bridgePort = _testWebSocketBridge.ListenPort;
Assert.NotEqual(0, _debugger.InternalDapPort);
Assert.NotEqual(0, bridgePort);
Assert.NotEqual(bridgePort, _debugger.InternalDapPort);
using var client = await ConnectWebSocketClientAsync(bridgePort);
await SendRequestAsync(client, new Request
{
Assert.Equal(4711, _debugger.ResolvePort());
Seq = 1,
Type = "request",
Command = "initialize"
});
var response = await ReadWebSocketDataUntilAsync(
client,
TimeSpan.FromSeconds(5),
"\"type\":\"response\"",
"\"command\":\"initialize\"",
"\"event\":\"initialized\"");
Assert.Contains("\"success\":true", response);
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task StartAsyncWithWebSocketBridgeAcceptsPreUpgradedWebSocketStream()
{
using (CreateTestContext(enableWebSocketBridge: true))
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, GetFreePort());
await _debugger.StartAsync(jobContext.Object);
var bridgePort = _testWebSocketBridge.ListenPort;
Assert.NotEqual(0, _debugger.InternalDapPort);
Assert.NotEqual(0, bridgePort);
Assert.NotEqual(bridgePort, _debugger.InternalDapPort);
using var tcpClient = await ConnectClientAsync(bridgePort);
using var webSocket = WebSocket.CreateFromStream(
tcpClient.GetStream(),
isServer: false,
subProtocol: null,
keepAliveInterval: TimeSpan.FromSeconds(30));
await SendRequestAsync(webSocket, new Request
{
Seq = 1,
Type = "request",
Command = "initialize"
});
var response = await ReadWebSocketDataUntilAsync(
webSocket,
TimeSpan.FromSeconds(5),
"\"type\":\"response\"",
"\"command\":\"initialize\"",
"\"event\":\"initialized\"");
Assert.Contains("\"success\":true", response);
await _debugger.StopAsync();
}
}
@@ -254,15 +431,12 @@ namespace GitHub.Runner.Common.Tests.Worker
using (CreateTestContext())
{
var port = GetFreePort();
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContext(cts.Token);
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
Assert.True(client.Connected);
await _debugger.StopAsync();
});
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
Assert.True(client.Connected);
await _debugger.StopAsync();
}
}
@@ -275,13 +449,10 @@ namespace GitHub.Runner.Common.Tests.Worker
{
foreach (var port in new[] { GetFreePort(), GetFreePort() })
{
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContext(cts.Token);
await _debugger.StartAsync(jobContext.Object);
await _debugger.StopAsync();
});
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
await _debugger.StopAsync();
}
}
}
@@ -294,25 +465,22 @@ namespace GitHub.Runner.Common.Tests.Worker
using (CreateTestContext())
{
var port = GetFreePort();
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
await SendRequestAsync(client.GetStream(), new Request
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContext(cts.Token);
await _debugger.StartAsync(jobContext.Object);
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
await SendRequestAsync(client.GetStream(), new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await waitTask;
Assert.Equal(DapSessionState.Ready, _debugger.State);
await _debugger.StopAsync();
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await waitTask;
Assert.Equal(DapSessionState.Ready, _debugger.State);
await _debugger.StopAsync();
}
}
@@ -324,25 +492,22 @@ namespace GitHub.Runner.Common.Tests.Worker
using (CreateTestContext())
{
var port = GetFreePort();
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(client.GetStream(), new Request
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContext(cts.Token, "ci-job");
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(client.GetStream(), new Request
{
Seq = 1,
Type = "request",
Command = "threads"
});
var response = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"threads\"", response);
Assert.Contains("\"name\":\"Job: ci-job\"", response);
await _debugger.StopAsync();
Seq = 1,
Type = "request",
Command = "threads"
});
var response = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"threads\"", response);
Assert.Contains("\"name\":\"Job: ci-job\"", response);
await _debugger.StopAsync();
}
}
@@ -354,30 +519,27 @@ namespace GitHub.Runner.Common.Tests.Worker
using (CreateTestContext())
{
var port = GetFreePort();
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
await SendRequestAsync(client.GetStream(), new Request
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContext(cts.Token);
await _debugger.StartAsync(jobContext.Object);
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
await SendRequestAsync(client.GetStream(), new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await waitTask;
cts.Cancel();
// In the real runner, JobRunner always calls OnJobCompletedAsync
// from a finally block. The cancellation callback only unblocks
// pending waits; OnJobCompletedAsync handles state + cleanup.
await _debugger.OnJobCompletedAsync();
Assert.Equal(DapSessionState.Terminated, _debugger.State);
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await waitTask;
cts.Cancel();
// In the real runner, JobRunner always calls OnJobCompletedAsync
// from a finally block. The cancellation callback only unblocks
// pending waits; OnJobCompletedAsync handles state + cleanup.
await _debugger.OnJobCompletedAsync();
Assert.Equal(DapSessionState.Terminated, _debugger.State);
}
}
@@ -400,25 +562,22 @@ namespace GitHub.Runner.Common.Tests.Worker
using (CreateTestContext())
{
var port = GetFreePort();
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
await SendRequestAsync(client.GetStream(), new Request
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContext(cts.Token);
await _debugger.StartAsync(jobContext.Object);
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
await SendRequestAsync(client.GetStream(), new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await waitTask;
await _debugger.OnJobCompletedAsync();
Assert.Equal(DapSessionState.Terminated, _debugger.State);
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await waitTask;
await _debugger.OnJobCompletedAsync();
Assert.Equal(DapSessionState.Terminated, _debugger.State);
}
}
@@ -441,20 +600,17 @@ namespace GitHub.Runner.Common.Tests.Worker
using (CreateTestContext())
{
var port = GetFreePort();
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContext(cts.Token);
await _debugger.StartAsync(jobContext.Object);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
var waitTask = _debugger.WaitUntilReadyAsync();
await Task.Delay(50);
cts.Cancel();
var waitTask = _debugger.WaitUntilReadyAsync();
await Task.Delay(50);
cts.Cancel();
var ex = await Assert.ThrowsAnyAsync<OperationCanceledException>(() => waitTask);
Assert.IsNotType<TimeoutException>(ex);
await _debugger.StopAsync();
});
var ex = await Assert.ThrowsAnyAsync<OperationCanceledException>(() => waitTask);
Assert.IsNotType<TimeoutException>(ex);
await _debugger.StopAsync();
}
}
@@ -471,32 +627,29 @@ namespace GitHub.Runner.Common.Tests.Worker
hc.SecretMasker.AddValue("initialized");
var port = GetFreePort();
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContext(cts.Token);
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "initialize"
});
var response = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"type\":\"response\"", response);
Assert.Contains("\"command\":\"initialize\"", response);
Assert.Contains("\"success\":true", response);
var initializedEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"type\":\"event\"", initializedEvent);
Assert.Contains("\"event\":\"initialized\"", initializedEvent);
await _debugger.StopAsync();
Seq = 1,
Type = "request",
Command = "initialize"
});
var response = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"type\":\"response\"", response);
Assert.Contains("\"command\":\"initialize\"", response);
Assert.Contains("\"success\":true", response);
var initializedEvent = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"type\":\"event\"", initializedEvent);
Assert.Contains("\"event\":\"initialized\"", initializedEvent);
await _debugger.StopAsync();
}
}
@@ -508,41 +661,38 @@ namespace GitHub.Runner.Common.Tests.Worker
using (CreateTestContext())
{
var port = GetFreePort();
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
// Complete handshake so session is ready
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContext(cts.Token);
await _debugger.StartAsync(jobContext.Object);
// Complete handshake so session is ready
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await waitTask;
// Simulate a step starting (which pauses)
var step = new Mock<IStep>();
step.Setup(s => s.DisplayName).Returns("Test Step");
step.Setup(s => s.ExecutionContext).Returns((IExecutionContext)null);
var stepTask = _debugger.OnStepStartingAsync(step.Object);
// Give the step time to pause
await Task.Delay(50);
// Cancel the job — should release the step pause
cts.Cancel();
await stepTask;
// In the real runner, OnJobCompletedAsync always follows.
await _debugger.OnJobCompletedAsync();
Assert.Equal(DapSessionState.Terminated, _debugger.State);
Seq = 1,
Type = "request",
Command = "configurationDone"
});
await waitTask;
// Simulate a step starting (which pauses)
var step = new Mock<IStep>();
step.Setup(s => s.DisplayName).Returns("Test Step");
step.Setup(s => s.ExecutionContext).Returns((IExecutionContext)null);
var stepTask = _debugger.OnStepStartingAsync(step.Object);
// Give the step time to pause
await Task.Delay(50);
// Cancel the job — should release the step pause
cts.Cancel();
await stepTask;
// In the real runner, OnJobCompletedAsync always follows.
await _debugger.OnJobCompletedAsync();
Assert.Equal(DapSessionState.Terminated, _debugger.State);
}
}
@@ -558,13 +708,10 @@ namespace GitHub.Runner.Common.Tests.Worker
// Start then immediate stop (no connection, no WaitUntilReady)
var port = GetFreePort();
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContext(cts.Token);
await _debugger.StartAsync(jobContext.Object);
await _debugger.StopAsync();
});
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
await _debugger.StopAsync();
// StopAsync after already stopped
await _debugger.StopAsync();
@@ -579,36 +726,86 @@ namespace GitHub.Runner.Common.Tests.Worker
using (CreateTestContext())
{
var port = GetFreePort();
await WithEnvironmentVariableAsync(PortEnvironmentVariable, port.ToString(), async () =>
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContext(cts.Token);
await _debugger.StartAsync(jobContext.Object);
Seq = 1,
Type = "request",
Command = "configurationDone"
});
var waitTask = _debugger.WaitUntilReadyAsync();
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
// Read the configurationDone response
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await waitTask;
// Read the configurationDone response
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await waitTask;
// Complete the job — events are sent via OnJobCompletedAsync
await _debugger.OnJobCompletedAsync();
// Complete the job — events are sent via OnJobCompletedAsync
await _debugger.OnJobCompletedAsync();
var msg1 = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
var msg2 = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
var msg1 = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
var msg2 = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
// Both events should arrive (order may vary)
var combined = msg1 + msg2;
Assert.Contains("\"event\":\"terminated\"", combined);
Assert.Contains("\"event\":\"exited\"", combined);
}
}
// Both events should arrive (order may vary)
var combined = msg1 + msg2;
Assert.Contains("\"event\":\"terminated\"", combined);
Assert.Contains("\"event\":\"exited\"", combined);
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ResolveTunnelConnectTimeoutReturnsDefaultWhenNoVariable()
{
using (CreateTestContext())
{
Assert.Equal(30, _debugger.ResolveTunnelConnectTimeout());
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ResolveTunnelConnectTimeoutUsesCustomValue()
{
using (CreateTestContext())
{
WithEnvironmentVariable(TunnelConnectTimeoutVariable, "60", () =>
{
Assert.Equal(60, _debugger.ResolveTunnelConnectTimeout());
});
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ResolveTunnelConnectTimeoutIgnoresInvalidValue()
{
using (CreateTestContext())
{
WithEnvironmentVariable(TunnelConnectTimeoutVariable, "not-a-number", () =>
{
Assert.Equal(30, _debugger.ResolveTunnelConnectTimeout());
});
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ResolveTunnelConnectTimeoutIgnoresZeroValue()
{
using (CreateTestContext())
{
WithEnvironmentVariable(TunnelConnectTimeoutVariable, "0", () =>
{
Assert.Equal(30, _debugger.ResolveTunnelConnectTimeout());
});
}
}
+80 -9
View File
@@ -1203,19 +1203,19 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
// TODO: this test can be deleted when `AddCheckRunIdToJobContext` is fully rolled out
// AddCheckRunIdToJobContext is now permanently enabled server-side (hardcoded to "true"
// in acquirejobhandler.go). The runner always copies ContextData["job"] entries, so the
// flag-disabled test is no longer applicable. Replaced with a test that verifies
// check_run_id is always hydrated regardless of the flag value.
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void InitializeJob_HydratesJobContextWithCheckRunId_FeatureFlagDisabled()
public void InitializeJob_HydratesJobContextWithCheckRunId_AlwaysCopied()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange: Create a job request message and make sure the feature flag is disabled
var variables = new Dictionary<string, VariableValue>()
{
[Constants.Runner.Features.AddCheckRunIdToJobContext] = new VariableValue("false"),
};
// Arrange: No feature flag set at all
var variables = new Dictionary<string, VariableValue>();
var jobRequest = new Pipelines.AgentJobRequestMessage(new TaskOrchestrationPlanReference(), new TimelineReference(), Guid.NewGuid(), "some job name", "some job name", null, null, null, variables, new List<MaskHint>(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List<Pipelines.ActionStep>(), null, null, null, null, null);
var pagingLogger = new Moq.Mock<IPagingLogger>();
var jobServerQueue = new Moq.Mock<IJobServerQueue>();
@@ -1233,9 +1233,80 @@ namespace GitHub.Runner.Common.Tests.Worker
// Act
ec.InitializeJob(jobRequest, CancellationToken.None);
// Assert
// Assert: check_run_id is always copied regardless of flag
Assert.NotNull(ec.JobContext);
Assert.Null(ec.JobContext.CheckRunId); // with the feature flag disabled we should not have added a CheckRunId to the JobContext
Assert.Equal(123456, ec.JobContext.CheckRunId);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void InitializeJob_HydratesJobContextWithWorkflowIdentity()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange
var variables = new Dictionary<string, VariableValue>();
var jobRequest = new Pipelines.AgentJobRequestMessage(new TaskOrchestrationPlanReference(), new TimelineReference(), Guid.NewGuid(), "some job name", "some job name", null, null, null, variables, new List<MaskHint>(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List<Pipelines.ActionStep>(), null, null, null, null, null);
var pagingLogger = new Moq.Mock<IPagingLogger>();
var jobServerQueue = new Moq.Mock<IJobServerQueue>();
hc.EnqueueInstance(pagingLogger.Object);
hc.SetSingleton(jobServerQueue.Object);
var ec = new Runner.Worker.ExecutionContext();
ec.Initialize(hc);
// Arrange: Server sends all 4 workflow identity fields
var jobContext = new Pipelines.ContextData.DictionaryContextData();
jobContext["workflow_ref"] = new StringContextData("my-org/my-repo/.github/workflows/reusable.yml@refs/heads/main");
jobContext["workflow_sha"] = new StringContextData("abc123def456");
jobContext["workflow_repository"] = new StringContextData("my-org/my-repo");
jobContext["workflow_file_path"] = new StringContextData(".github/workflows/reusable.yml");
jobRequest.ContextData["job"] = jobContext;
jobRequest.ContextData["github"] = new Pipelines.ContextData.DictionaryContextData();
// Act
ec.InitializeJob(jobRequest, CancellationToken.None);
// Assert: all properties hydrated from server
Assert.NotNull(ec.JobContext);
Assert.Equal("my-org/my-repo/.github/workflows/reusable.yml@refs/heads/main", ec.JobContext.WorkflowRef);
Assert.Equal("abc123def456", ec.JobContext.WorkflowSha);
Assert.Equal("my-org/my-repo", ec.JobContext.WorkflowRepository);
Assert.Equal(".github/workflows/reusable.yml", ec.JobContext.WorkflowFilePath);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void InitializeJob_WorkflowIdentityNotSet_WhenServerSendsNoData()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange: Server sends no workflow identity in job context
var variables = new Dictionary<string, VariableValue>();
var jobRequest = new Pipelines.AgentJobRequestMessage(new TaskOrchestrationPlanReference(), new TimelineReference(), Guid.NewGuid(), "some job name", "some job name", null, null, null, variables, new List<MaskHint>(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List<Pipelines.ActionStep>(), null, null, null, null, null);
var pagingLogger = new Moq.Mock<IPagingLogger>();
var jobServerQueue = new Moq.Mock<IJobServerQueue>();
hc.EnqueueInstance(pagingLogger.Object);
hc.SetSingleton(jobServerQueue.Object);
var ec = new Runner.Worker.ExecutionContext();
ec.Initialize(hc);
// Arrange: empty job context
jobRequest.ContextData["job"] = new Pipelines.ContextData.DictionaryContextData();
jobRequest.ContextData["github"] = new Pipelines.ContextData.DictionaryContextData();
// Act
ec.InitializeJob(jobRequest, CancellationToken.None);
// Assert: no workflow identity
Assert.NotNull(ec.JobContext);
Assert.Null(ec.JobContext.WorkflowRef);
Assert.Null(ec.JobContext.WorkflowSha);
Assert.Null(ec.JobContext.WorkflowRepository);
Assert.Null(ec.JobContext.WorkflowFilePath);
}
}
+104
View File
@@ -34,5 +34,109 @@ namespace GitHub.Runner.Common.Tests.Worker
ctx.CheckRunId = null;
Assert.Null(ctx.CheckRunId);
}
[Fact]
public void WorkflowRef_SetAndGet_WorksCorrectly()
{
var ctx = new JobContext();
ctx.WorkflowRef = "owner/repo/.github/workflows/ci.yml@refs/heads/main";
Assert.Equal("owner/repo/.github/workflows/ci.yml@refs/heads/main", ctx.WorkflowRef);
Assert.True(ctx.TryGetValue("workflow_ref", out var value));
Assert.IsType<StringContextData>(value);
}
[Fact]
public void WorkflowRef_NotSet_ReturnsNull()
{
var ctx = new JobContext();
Assert.Null(ctx.WorkflowRef);
}
[Fact]
public void WorkflowRef_SetNull_ClearsValue()
{
var ctx = new JobContext();
ctx.WorkflowRef = "owner/repo/.github/workflows/ci.yml@refs/heads/main";
ctx.WorkflowRef = null;
Assert.Null(ctx.WorkflowRef);
}
[Fact]
public void WorkflowSha_SetAndGet_WorksCorrectly()
{
var ctx = new JobContext();
ctx.WorkflowSha = "abc123def456";
Assert.Equal("abc123def456", ctx.WorkflowSha);
Assert.True(ctx.TryGetValue("workflow_sha", out var value));
Assert.IsType<StringContextData>(value);
}
[Fact]
public void WorkflowSha_NotSet_ReturnsNull()
{
var ctx = new JobContext();
Assert.Null(ctx.WorkflowSha);
}
[Fact]
public void WorkflowSha_SetNull_ClearsValue()
{
var ctx = new JobContext();
ctx.WorkflowSha = "abc123def456";
ctx.WorkflowSha = null;
Assert.Null(ctx.WorkflowSha);
}
[Fact]
public void WorkflowRepository_SetAndGet_WorksCorrectly()
{
var ctx = new JobContext();
ctx.WorkflowRepository = "owner/repo";
Assert.Equal("owner/repo", ctx.WorkflowRepository);
Assert.True(ctx.TryGetValue("workflow_repository", out var value));
Assert.IsType<StringContextData>(value);
}
[Fact]
public void WorkflowRepository_NotSet_ReturnsNull()
{
var ctx = new JobContext();
Assert.Null(ctx.WorkflowRepository);
}
[Fact]
public void WorkflowRepository_SetNull_ClearsValue()
{
var ctx = new JobContext();
ctx.WorkflowRepository = "owner/repo";
ctx.WorkflowRepository = null;
Assert.Null(ctx.WorkflowRepository);
}
[Fact]
public void WorkflowFilePath_SetAndGet_WorksCorrectly()
{
var ctx = new JobContext();
ctx.WorkflowFilePath = ".github/workflows/ci.yml";
Assert.Equal(".github/workflows/ci.yml", ctx.WorkflowFilePath);
Assert.True(ctx.TryGetValue("workflow_file_path", out var value));
Assert.IsType<StringContextData>(value);
}
[Fact]
public void WorkflowFilePath_NotSet_ReturnsNull()
{
var ctx = new JobContext();
Assert.Null(ctx.WorkflowFilePath);
}
[Fact]
public void WorkflowFilePath_SetNull_ClearsValue()
{
var ctx = new JobContext();
ctx.WorkflowFilePath = ".github/workflows/ci.yml";
ctx.WorkflowFilePath = null;
Assert.Null(ctx.WorkflowFilePath);
}
}
}
+266
View File
@@ -0,0 +1,266 @@
using System;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using GitHub.Runner.Common;
using GitHub.Runner.Worker.Dap;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class WebSocketDapBridgeL0
{
private TestHostContext CreateTestContext([CallerMemberName] string testName = "")
{
return new TestHostContext(this, testName);
}
private static async Task<byte[]> ReadWebSocketMessageAsync(ClientWebSocket client, TimeSpan timeout)
{
using var cts = new CancellationTokenSource(timeout);
using var buffer = new MemoryStream();
var receiveBuffer = new byte[1024];
while (true)
{
var result = await client.ReceiveAsync(new ArraySegment<byte>(receiveBuffer), cts.Token);
if (result.MessageType == WebSocketMessageType.Close)
{
throw new EndOfStreamException("WebSocket closed unexpectedly.");
}
if (result.Count > 0)
{
buffer.Write(receiveBuffer, 0, result.Count);
}
if (result.EndOfMessage)
{
return buffer.ToArray();
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task BridgeForwardsWebSocketFramesToTcpAndBack()
{
using var hc = CreateTestContext();
using var targetListener = new TcpListener(IPAddress.Loopback, 0);
targetListener.Start();
var targetPort = ((IPEndPoint)targetListener.LocalEndpoint).Port;
var bridge = new WebSocketDapBridge();
bridge.Initialize(hc);
bridge.Start(0, targetPort);
var bridgePort = bridge.ListenPort;
try
{
var echoTask = Task.Run(async () =>
{
using var targetClient = await targetListener.AcceptTcpClientAsync();
using var stream = targetClient.GetStream();
var headerBuilder = new StringBuilder();
var buffer = new byte[1];
var contentLength = -1;
while (true)
{
var bytesRead = await stream.ReadAsync(buffer, 0, 1);
if (bytesRead == 0) break;
headerBuilder.Append((char)buffer[0]);
var headers = headerBuilder.ToString();
if (headers.EndsWith("\r\n\r\n", StringComparison.Ordinal))
{
foreach (var line in headers.Split(new[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries))
{
if (line.StartsWith("Content-Length: ", StringComparison.OrdinalIgnoreCase))
{
contentLength = int.Parse(line.Substring("Content-Length: ".Length).Trim());
}
}
break;
}
}
var body = new byte[contentLength];
var totalRead = 0;
while (totalRead < contentLength)
{
var bytesRead = await stream.ReadAsync(body, totalRead, contentLength - totalRead);
if (bytesRead == 0) break;
totalRead += bytesRead;
}
var header = $"Content-Length: {body.Length}\r\n\r\n";
var headerBytes = Encoding.ASCII.GetBytes(header);
await stream.WriteAsync(headerBytes, 0, headerBytes.Length);
await stream.WriteAsync(body, 0, body.Length);
await stream.FlushAsync();
});
using var client = new ClientWebSocket();
client.Options.Proxy = null;
await client.ConnectAsync(new Uri($"ws://127.0.0.1:{bridgePort}/"), CancellationToken.None);
var dapMessage = "{\"type\":\"request\",\"seq\":1,\"command\":\"initialize\"}";
var payload = Encoding.UTF8.GetBytes(dapMessage);
await client.SendAsync(new ArraySegment<byte>(payload), WebSocketMessageType.Text, endOfMessage: true, CancellationToken.None);
var echoed = await ReadWebSocketMessageAsync(client, TimeSpan.FromSeconds(5));
Assert.Equal(payload, echoed);
await echoTask;
}
finally
{
await bridge.ShutdownAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task BridgeRejectsNonWebSocketRequests()
{
using var hc = CreateTestContext();
var bridge = new WebSocketDapBridge();
bridge.Initialize(hc);
bridge.Start(0, 0);
var bridgePort = bridge.ListenPort;
try
{
using var client = new TcpClient();
await client.ConnectAsync(IPAddress.Loopback, bridgePort);
using var stream = client.GetStream();
var request = Encoding.ASCII.GetBytes(
"GET / HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"\r\n");
await stream.WriteAsync(request, 0, request.Length);
await stream.FlushAsync();
// Read until the server closes the connection (Connection: close).
// A single ReadAsync may return a partial response on some platforms.
using var ms = new MemoryStream();
var responseBuffer = new byte[1024];
int bytesRead;
while ((bytesRead = await stream.ReadAsync(responseBuffer, 0, responseBuffer.Length)) > 0)
{
ms.Write(responseBuffer, 0, bytesRead);
}
var response = Encoding.ASCII.GetString(ms.ToArray());
Assert.Contains("400 BadRequest", response);
Assert.Contains("Expected a websocket upgrade request.", response);
}
finally
{
await bridge.ShutdownAsync();
}
}
[Theory]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
[InlineData(new byte[] { (byte)'G', (byte)'E', (byte)'T', (byte)' ' }, 1)]
[InlineData(new byte[] { 0x81, 0x85, 0x00, 0x00 }, 2)]
[InlineData(new byte[] { 0xC1, 0x85, 0x00, 0x00 }, 3)]
[InlineData(new byte[] { (byte)'P', (byte)'R', (byte)'I', (byte)' ' }, 4)]
[InlineData(new byte[] { 0x16, 0x03, 0x03, 0x01 }, 5)]
[InlineData(new byte[] { (byte)'B', (byte)'A', (byte)'D', (byte)'!' }, 0)]
public void ClassifyIncomingStreamPrefixDetectsExpectedProtocols(byte[] initialBytes, int expectedKind)
{
var actualKind = WebSocketDapBridge.ClassifyIncomingStreamPrefix(initialBytes);
Assert.Equal((WebSocketDapBridge.IncomingStreamPrefixKind)expectedKind, actualKind);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task BridgeRejectsOversizedWebSocketMessage()
{
using var hc = CreateTestContext();
using var targetListener = new TcpListener(IPAddress.Loopback, 0);
targetListener.Start();
var targetPort = ((IPEndPoint)targetListener.LocalEndpoint).Port;
var bridge = new WebSocketDapBridge();
bridge.Initialize(hc);
bridge.MaxInboundMessageSize = 64; // artificially small limit for testing
bridge.Start(0, targetPort);
var bridgePort = bridge.ListenPort;
try
{
using var client = new ClientWebSocket();
client.Options.Proxy = null;
await client.ConnectAsync(new Uri($"ws://127.0.0.1:{bridgePort}/"), CancellationToken.None);
// Send a message that exceeds the 64-byte limit
var oversizedPayload = new byte[128];
Array.Fill(oversizedPayload, (byte)'X');
await client.SendAsync(
new ArraySegment<byte>(oversizedPayload),
WebSocketMessageType.Text,
endOfMessage: true,
CancellationToken.None);
// The bridge should close the connection with MessageTooBig
var receiveBuffer = new byte[256];
using var receiveCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var result = await client.ReceiveAsync(
new ArraySegment<byte>(receiveBuffer),
receiveCts.Token);
Assert.Equal(WebSocketMessageType.Close, result.MessageType);
Assert.Equal(WebSocketCloseStatus.MessageTooBig, client.CloseStatus);
}
finally
{
await bridge.ShutdownAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task BridgeShutdownCompletesWhenPeerDoesNotCloseGracefully()
{
using var hc = CreateTestContext();
using var targetListener = new TcpListener(IPAddress.Loopback, 0);
targetListener.Start();
var targetPort = ((IPEndPoint)targetListener.LocalEndpoint).Port;
var bridge = new WebSocketDapBridge();
bridge.Initialize(hc);
bridge.Start(0, targetPort);
var bridgePort = bridge.ListenPort;
// Connect a raw TCP client but never perform WebSocket close handshake
using var rawClient = new TcpClient();
await rawClient.ConnectAsync(IPAddress.Loopback, bridgePort);
// Shutdown should complete within a bounded time, not hang
var shutdownTask = bridge.ShutdownAsync();
var completed = await Task.WhenAny(shutdownTask, Task.Delay(TimeSpan.FromSeconds(15)));
Assert.True(completed == shutdownTask, "Bridge shutdown should complete within the timeout, not hang on a non-cooperative peer");
}
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ LAYOUT_DIR="$SCRIPT_DIR/../_layout"
DOWNLOAD_DIR="$SCRIPT_DIR/../_downloads/netcore2x"
PACKAGE_DIR="$SCRIPT_DIR/../_package"
DOTNETSDK_ROOT="$SCRIPT_DIR/../_dotnetsdk"
DOTNETSDK_VERSION="8.0.419"
DOTNETSDK_VERSION="8.0.420"
DOTNETSDK_INSTALLDIR="$DOTNETSDK_ROOT/$DOTNETSDK_VERSION"
RUNNER_VERSION=$(cat runnerversion)
+1 -1
View File
@@ -1,5 +1,5 @@
{
"sdk": {
"version": "8.0.419"
"version": "8.0.420"
}
}
+1 -1
View File
@@ -1 +1 @@
2.333.0
2.334.0