6.7 KiB
DAP Step Backward: Duplicate Expression Function Fix
Status: Ready for Implementation
Date: January 2026
Related: dap-step-backwards.md
Problem
When stepping backward and then forward again during DAP debugging, the runner crashes with:
System.ArgumentException: An item with the same key has already been added. Key: always
at System.Collections.Generic.Dictionary`2.TryInsert(...)
at GitHub.DistributedTask.Expressions2.ExpressionParser.ParseContext..ctor(...)
Reproduction Steps
- Run a workflow with DAP debugging enabled
- Let a step execute (e.g.,
cat doesnotexist) - Before the next step runs, step backward
- Optionally run REPL commands
- Step forward to re-run the step
- Step forward again → CRASH
Root Cause Analysis
The Bug
In StepsRunner.cs:89-93, expression functions are added to step.ExecutionContext.ExpressionFunctions every time a step is processed:
// Expression functions
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<AlwaysFunction>(PipelineTemplateConstants.Always, 0, 0));
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<CancelledFunction>(PipelineTemplateConstants.Cancelled, 0, 0));
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<FailureFunction>(PipelineTemplateConstants.Failure, 0, 0));
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<SuccessFunction>(PipelineTemplateConstants.Success, 0, 0));
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<HashFilesFunction>(PipelineTemplateConstants.HashFiles, 1, byte.MaxValue));
Why It Fails on Step-Back
- First execution: Step is dequeued, functions added to
ExpressionFunctions, step runs - Checkpoint created: Stores a reference to the
IStepobject (not a deep copy) - seeStepCheckpoint.cs:65 - Step backward: Checkpoint is restored, the same
IStepobject is re-queued tojobContext.JobSteps - Second execution: Step is dequeued again, functions added again to the same
ExpressionFunctionslist - Duplicate entries: The list now has two
AlwaysFunctionentries, twoCancelledFunctionentries, etc. - Crash: When
ExpressionParser.ParseContextconstructor iterates over functions and adds them to aDictionary(ExpressionParser.cs:460-465), it throws on the duplicate key "always"
Key Insight
The ExpressionFunctions property on ExecutionContext is a List<IFunctionInfo> (ExecutionContext.cs:199). List<T>.Add() doesn't check for duplicates, so the functions get added twice. The error only manifests later when the expression parser builds its internal dictionary.
Solution
Chosen Approach: Clear ExpressionFunctions Before Adding
Clear the ExpressionFunctions list before adding the functions. This ensures a known state regardless of how the step arrived in the queue (fresh or restored from checkpoint).
Why This Approach
| Approach | Pros | Cons |
|---|---|---|
| Clear before adding (chosen) | Simple, explicit, ensures known state, works for any re-processing scenario | Slightly more work than strictly necessary on first run |
| Check before adding | Defensive | More complex, multiple conditions to check |
| Reset on checkpoint restore | Localized to DAP | Requires changes in multiple places, easy to miss edge cases |
The "clear before adding" approach is:
- Simple: One line of code
- Robust: Works regardless of why the step is being re-processed
- Safe: The functions are always the same set, so clearing and re-adding has no side effects
- Future-proof: If other code paths ever re-queue steps, this handles it automatically
Implementation
File to Modify
src/Runner.Worker/StepsRunner.cs
Change
// Before line 88, add:
step.ExecutionContext.ExpressionFunctions.Clear();
// Expression functions
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<AlwaysFunction>(PipelineTemplateConstants.Always, 0, 0));
// ... rest of the adds
Full Context (lines ~85-94)
Before:
// Start
step.ExecutionContext.Start();
// Expression functions
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<AlwaysFunction>(PipelineTemplateConstants.Always, 0, 0));
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<CancelledFunction>(PipelineTemplateConstants.Cancelled, 0, 0));
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<FailureFunction>(PipelineTemplateConstants.Failure, 0, 0));
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<SuccessFunction>(PipelineTemplateConstants.Success, 0, 0));
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<HashFilesFunction>(PipelineTemplateConstants.HashFiles, 1, byte.MaxValue));
After:
// Start
step.ExecutionContext.Start();
// Expression functions
// Clear first to handle step-back scenarios where the same step may be re-processed
step.ExecutionContext.ExpressionFunctions.Clear();
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<AlwaysFunction>(PipelineTemplateConstants.Always, 0, 0));
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<CancelledFunction>(PipelineTemplateConstants.Cancelled, 0, 0));
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<FailureFunction>(PipelineTemplateConstants.Failure, 0, 0));
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<SuccessFunction>(PipelineTemplateConstants.Success, 0, 0));
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<HashFilesFunction>(PipelineTemplateConstants.HashFiles, 1, byte.MaxValue));
Testing
Manual Test Scenario
- Create a workflow with multiple steps
- Enable DAP debugging
- Let step 1 execute
- Pause before step 2
- Step backward (restore to before step 1)
- Step forward (re-run step 1)
- Step forward again (run step 2)
- Verify: No crash, step 2's condition evaluates correctly
Edge Cases to Verify
- Step backward multiple times in a row
- Step backward then run REPL commands, then step forward
reverseContinueto beginning, then step through all steps again- Steps with
if: always()condition (the specific function that was failing) - Steps with
if: failure()orif: cancelled()conditions
Risk Assessment
Risk: Low
- The fix is minimal (one line)
ExpressionFunctionsis always populated with the same 5 functions at this point- No other code depends on functions being accumulated across step re-runs
- Normal (non-DAP) execution is unaffected since steps are never re-queued
Files Summary
| File | Change |
|---|---|
src/Runner.Worker/StepsRunner.cs |
Add Clear() call before adding expression functions |