Jenkins Groovy: CPS Hell, @NonCPS, and Shared Libraries – A Survival Guide
Master Jenkins Groovy scripting: CPS transformation, @NonCPS, try/catch patterns, Script Console debugging, and shared libraries.
20+ years shipping production infrastructure and CI/CD at scale. Written from production experience, not tutorials.
- ✓Deep devops experience in a production environment
- ✓Familiarity with debugging, profiling, and performance analysis
- ✓Understanding of distributed systems and failure modes
- Groovy in Jenkins pipelines undergoes CPS transformation: methods are split into continuations for resumability, which breaks standard Java/Groovy constructs like loops and try/catch.
- Use
@NonCPSto mark methods that should run without CPS transformation – ideal for pure data processing, JSON parsing, or any code that doesn't need pipeline state. - Never use
JsonSlurperorXmlSlurperinside a CPS-transformed method – they cause serialization errors. Instead, callsh '...'with jq or xmllint. - Shared libraries must be loaded with
library 'my-lib@branch'and the Groovy code invars/is automatically CPS-transformed – use @NonCPS inside helper classes. - Script Console (
/script) is your best friend for debugging: runprintln GroovySystem.versionto check Groovy version, test snippets, and inspect serialization issues. - Always wrap pipeline steps in
try/catch/finallyto handle failures gracefully – but remember that CPS-transformed try/catch can't catch errors from@NonCPSmethods if they throw. - Avoid storing complex objects (like
ArrayListofHashMap) in pipeline variables – they must be serializable. Use@NonCPSmethods to process data and return simple types. - For lists and maps, use
def list = []anddef map = [:]– but be aware that closures inside CPS can causeNotSerializableExceptionif they capture non-serializable state.
Imagine you're baking a cake but the recipe is frozen mid-step every time the oven timer goes off. You can't just keep going – you have to write down exactly where you left off, then pick up again later. That's CPS transformation. Jenkins pipelines need to be paused and resumed (e.g., waiting for user approval), so Groovy code is broken into tiny steps that can be saved and restored.
But this magic comes at a cost. Some things you normally do in programming – like using a for loop with a counter, or reading a file – break because the 'freeze and resume' process doesn't handle them well. So you have to write code in a special way, marking certain methods with @NonCPS to say 'this part doesn't need to be paused, just run it normally.'
Think of it like a relay race: most of the pipeline is a relay where batons (state) must be passed carefully, but some parts are solo sprints where you just run flat out. The trick is knowing which parts need the baton and which don't.
I still remember the 2 AM call: 'The pipeline is stuck at 'Loading shared library' and nobody can deploy.' The Jenkins master had been running for 200 days, and a recent Groovy update had broken our entire CI/CD. The root cause? A JsonSlurper call in a CPS-transformed method that had worked for years suddenly started throwing NotSerializableException. That night, I learned the hard way what CPS transformation really means.
Jenkins Pipeline was introduced in 2016 with the 'Scripted Pipeline' DSL, allowing you to write build logic in Groovy. But Groovy wasn't designed for long-running, resumable workflows. To make it work, Jenkins applies a 'Continuation Passing Style' (CPS) transformation to your code. This rewrites your methods into a state machine that can be serialized and deserialized at every step – enabling the pipeline to survive Jenkins restarts and agent disconnections.
But CPS transformation has deep implications. Standard Groovy patterns – loops, closures, exception handling, even simple string concatenation – can behave unexpectedly or fail entirely. In this article, I'll share the exact patterns I've used in production to avoid 'CPS hell.' We'll cover Groovy basics (def, closures, lists, maps) through a pipeline lens, error handling with try/catch/finally, the @NonCPS annotation and its serialization caveats, Script Console debugging, why you should avoid JsonSlurper and use shell commands instead, and how to structure shared libraries to minimize CPS pain.
By the end, you'll be able to write robust, maintainable Jenkins pipelines that won't wake you up at 2 AM.
Groovy Basics for Pipelines: def, Closures, Lists, Maps
In Jenkins pipelines, def is your go-to for variable declaration. Unlike Java's static typing, Groovy's def uses dynamic typing – but be careful: CPS transformation can cause type inference issues. Always declare variables with def to avoid binding problems.
Lists: Use def list = [] or def list = [1, 2, 3]. CPS handles lists well, but avoid modifying them inside a closure that captures non-serializable state. Example:
``groovy def stages = ['build', 'test', 'deploy'] stages.each { stage -> stage 'Stage: ' + stage } ``
Maps: Use def map = [:] or def map = [key: 'value']. Access with map.key or map['key']. Maps are serializable as long as keys/values are serializable.
Closures: Closures are first-class citizens in Groovy but are tricky in CPS. A closure that captures a variable from the enclosing scope forces that variable to be serializable. Avoid capturing Jenkins.getInstance() or other non-serializable objects. Instead, pass data as parameters:
``groovy def processData(data, Closure callback) { callback(data) } // Call with a lambda processData('test') { d -> echo d } ``
Production insight: I once had a closure that captured a Jenkins instance variable. The pipeline would fail intermittently with serialization errors. The fix was to pass the Jenkins URL as a string instead of the object.
def jenkins = Jenkins.getInstance(), the closure becomes non-serializable. Always pass simple types (String, int) into closures.Jenkins instance variable. The pipeline would fail intermittently with serialization errors. The fix was to pass the Jenkins URL as a string instead of the object.def for all variables, prefer each loops, and never capture non-serializable objects in closures.Try/Catch/Finally Error Handling in Scripted Pipeline
Scripted Pipeline supports try/catch/finally blocks, but CPS transformation changes how they work. A try/catch inside a CPS method can catch exceptions thrown by pipeline steps, but it cannot catch exceptions thrown inside a @NonCPS method – those propagate directly and may bypass serialization.
Pattern: Always wrap pipeline steps in try/catch/finally to ensure cleanup:
``groovy try { sh 'make build' } catch (Exception e) { currentBuild.result = 'FAILURE' echo "Build failed: ${e.message}" throw e // rethrow to fail the pipeline } finally { archiveArtifacts artifacts: 'build/*.zip' } ``
Gotcha: If you have a @NonCPS method that throws an exception, it won't be caught by a CPS try/catch if the method is called inside the try block? Actually, it will be caught, but the stack trace will be mangled. More importantly, if the @NonCPS method uses throw with a non-serializable exception, serialization fails.
Best practice: Keep @NonCPS methods pure (no I/O, no pipeline steps) and let them return success/failure flags rather than throwing exceptions. Use a wrapper method that is CPS to handle exceptions:
```groovy @NonCPS def parseData(String data) { if (data == null) throw new IllegalArgumentException('data cannot be null') // parse return result }
def safeParse(String data) { try { return parseData(data) } catch (IllegalArgumentException e) { echo 'Parse failed: ' + e.message return null } } ```
error(...) or rethrow to propagate failure.finally block that called emailext to notify on failure. Because the pipeline had already failed, the finally block ran but the email step threw another exception, masking the original error. We added a nested try/catch around email.try/catch/finally around pipeline steps, but keep @NonCPS methods exception-safe.CPS Transformation: How It Works and Its Limitations
CPS transformation is the magic that makes pipelines resumable. When Jenkins compiles your Groovy script, it uses the workflow-cps plugin to rewrite the code into a state machine. Each 'step' (any method that can suspend, like sh, input, sleep) becomes a checkpoint. The entire program state (local variables, call stack) is serialized to disk.
Limitations: 1. Loops: Only for (item in iterable), while, and list.each {} are supported. Classic for (int i=0; i<n; i++) is not because the CPS transformer can't handle the mutable integer i as a continuation variable. 2. Switch statements: Not supported. Use if/else if chains. 3. Closures: Must be serializable. Avoid capturing non-serializable objects. 4. Recursion: Can cause stack overflow because each recursive call creates a new continuation. 5. Threading: No Thread.start() or parallel execution without parallel step.
How to check if a method is CPS: If it calls any pipeline step (like sh, echo, stage), it's CPS. If it's annotated with @NonCPS, it's not.
Debugging CPS: Use Script Console to inspect the transformed code? Not directly, but you can run println 'Hello' and see if it prints – if it doesn't, CPS may have swallowed the output. Use echo instead of println in pipelines.
for (int i=0; i<3; i++) to retry a build. It worked for months until a Jenkins upgrade changed the CPS transformer behavior, causing MissingMethodException. We changed to (1..3).each { attempt -> ... } and it worked.Using @NonCPS Annotation Correctly
The @NonCPS annotation tells Jenkins to run the method without CPS transformation. This means the method runs atomically – it cannot be paused or resumed. Use it for: - Data processing (parsing JSON, XML, regex) - Pure computation (sorting, filtering) - Any code that doesn't need to call pipeline steps
Important: Inside a @NonCPS method, you cannot use sh, echo, stage, or any pipeline step. If you try, you'll get a MissingMethodException or a serialization error.
Serialization: Even though the method isn't CPS-transformed, its arguments and return value must be serializable because the caller's CPS context will serialize them at the next checkpoint. For example:
``groovy @NonCPS def process(List<String> items) { return items.collect { it.toUpperCase() } } ` Here, List<String>` is serializable, so it's safe.
Common mistake: Annotating a method that calls echo – this will fail. Instead, structure your code so that @NonCPS methods are pure, and CPS methods call them:
```groovy def myPipelineStep() { def raw = sh(script: 'cat data.json', returnStdout: true) def processed = processData(raw) // @NonCPS echo "Processed: $processed" }
@NonCPS def processData(String raw) { // parse with regex or simple string manipulation return raw.split(' ').find { it.contains('key') } } ```
sh or echo, move that to the caller CPS method. @NonCPS methods must be pure functions.sh 'some command'. The pipeline compiled but failed at runtime with java.lang.IllegalStateException: Cannot call method sh on a non-CPS method. We refactored to separate the shell call from the processing.Serialization Considerations in Jenkins Groovy
Serialization is the backbone of pipeline resumability. Every time the pipeline reaches a checkpoint (e.g., after a sh step), Jenkins serializes the entire CPS execution state – including all local variables, the call stack, and any objects referenced by closures. If any object in this graph is not serializable, the pipeline crashes with NotSerializableException.
What is serializable? - Primitive types (int, boolean, etc.) - String, Number, Date - Arrays and Collections (List, Map, Set) of serializable elements - Classes that implement java.io.Serializable - Groovy's GString (but avoid using it as a variable – use String instead)
What is NOT serializable? - groovy.json.JsonSlurper, groovy.xml.XmlSlurper - Jenkins, Jenkins.getInstance() - java.io.InputStream, java.io.OutputStream - Custom classes without Serializable
Pattern: Minimize the scope of non-serializable objects. For example, if you need to read a file, open the stream, read the data, and close it all within a single @NonCPS method:
``groovy @NonCPS def readFileContent(String path) { def file = new File(path) def content = file.text return content } ` But be careful: File is serializable? Actually, java.io.File implements Serializable, so it's safe. But FileReader` is not.
Debugging serialization: Use Script Console to test if an object is serializable:
``groovy def obj = [1, 2, 3] println obj instanceof Serializable // true ``
println myObject instanceof Serializable to quickly test if an object will cause issues.HashMap<String, Object> where the value was a File object. The File was serializable, but the HashMap itself was not because of a custom class loader. We changed the value to a String path.Script Console: Your Best Debugging Tool
The Jenkins Script Console at http://jenkins:8080/script allows you to run arbitrary Groovy code on the master. It's invaluable for debugging pipelines because you can test snippets without running a full pipeline.
- Check Groovy version:
println GroovySystem.version - Test serialization:
println [1,2,3] instanceof Serializable - Inspect Jenkins state:
println Jenkins.instance.pluginManager.plugins - Approve blocked methods:
hudson.model.(but normally you use the UI)UsageStatistics.get()?.doPostCheck()
Warning: Script Console runs with full permissions. Be careful not to run destructive commands like System.exit(1).
Debugging a specific pipeline: You can get the current build's CPS execution state:
``groovy def job = Jenkins.instance.getItemByFullName('my-job') def build = job.getBuildByNumber(123) println build.getExecution()?.serialize() ``
Testing CPS behavior: You can't directly simulate CPS in Script Console, but you can write a small test:
``groovy import org.jenkinsci.plugins.workflow.cps.CpsScript // Not directly usable, but you can test if a class is serializable ``
HashMap entry. The console saved hours of trial and error.Why You Should Avoid JsonSlurper and XmlSlurper (Use Shell Instead)
JsonSlurper and XmlSlurper are convenient Groovy classes for parsing structured data, but they are NOT serializable. Using them inside a CPS-transformed pipeline method will cause NotSerializableException at the next checkpoint.
Alternative: Use shell commands with jq for JSON and xmllint for XML. These tools are fast, reliable, and don't require serialization because the pipeline only sees the output string.
Example: Parsing JSON:
``groovy def json = sh(script: 'echo \'{"name":"test"}\' | jq -r \'.name\'', returnStdout: true).trim() ``
Example: Parsing XML:
``groovy def value = sh(script: 'echo \'<root><element>value</element></root>\' | xmllint --xpath \'string(//element/text())\' -', returnStdout: true).trim() ``
Handling complex data: If you need to extract multiple fields, write a shell script that outputs a simple format (e.g., key=value lines) and parse that in Groovy:
``groovy def output = sh(script: 'jq -r \'to_entries[] | "\(.key)=\(.value)"\' data.json', returnStdout: true) def map = [:] output.readLines().each { line -> def parts = line.split('=', 2) map[parts[0]] = parts[1] } ``
Performance: Shell parsing is often faster than Groovy parsing for large files, and it avoids serialization issues entirely.
Shared Library Patterns in Groovy
Shared libraries allow you to reuse pipeline logic across multiple repositories. The library is stored in a Git repo and loaded with library 'my-lib@branch'. The Groovy files in vars/ are automatically loaded as global variables and are CPS-transformed.
Structure: `` (root) +- vars/ | +- myStep.groovy // defines a function myStep | +- myMethod.groovy // defines a function myMethod +- src/ +- org/my/lib/ +- MyHelper.groovy // helper class, not CPS-transformed ``
Best practices: 1. Keep vars/ methods thin – they should call helper classes for heavy logic. 2. Use @NonCPS in src/ classes for data processing. 3. Avoid storing state in global variables – use method parameters. 4. Use library 'my-lib@v1.2.3' to pin versions.
Example:
```groovy // vars/buildApp.groovy def call(String project, String branch) { def helper = new org.my.lib.BuildHelper() def config = helper.loadConfig(project) stage('Build') { sh "make -C ${config.dir}" } }
// src/org/my/lib/BuildHelper.groovy @NonCPS def loadConfig(String project) { def config = [:] // read from file or API return config } ```
Gotcha: If you use return in a vars/ method, the return value must be serializable. If you return a complex object, ensure it implements Serializable.
Testing: You can test shared library code locally by running groovy command-line, but you'll need to mock Jenkins steps.
library 'my-lib@v1.2.3' to avoid unexpected changes breaking your pipelines.org.yaml.snakeyaml. The Yaml object is not serializable, causing random failures. We moved the YAML parsing to a @NonCPS helper and passed the result as a map.vars/ methods and thick @NonCPS helper classes for heavy lifting.Advanced Error Handling: Retry and Timeout Patterns
Jenkins provides built-in steps retry and timeout. Combine them with try/catch for robust error handling.
Retry with backoff: Jenkins doesn't have built-in exponential backoff, but you can implement it:
``groovy def retryWithBackoff(int maxRetries, int initialDelay = 10, Closure body) { for (int attempt = 1; attempt <= maxRetries; attempt++) { try { ``body() return } catch (Exception e) { if (attempt == maxRetries) throw e echo "Attempt $attempt failed, retrying in ${initialDelay attempt} seconds" sleep time: initialDelay attempt, unit: 'SECONDS' } } }
Timeout: Use timeout to prevent hung builds:
``groovy timeout(time: 30, unit: 'MINUTES') { sh 'make test' } ``
Combined:
``groovy timeout(time: 1, unit: 'HOURS') { retry(3) { sh 'deploy' } } ``
Gotcha: retry step internally catches exceptions and retries, but if the exception is not serializable, it can fail. Ensure your body doesn't throw non-serializable exceptions.
Production insight: We had a pipeline that retried a database migration. The migration threw a custom exception that wasn't serializable, causing the retry to fail with serialization error. We changed to catch and rethrow a RuntimeException.
retry and timeout are optimized for CPS. Implement custom retry only when you need backoff.RuntimeException.retry, timeout, and try/catch for robust error handling, but ensure exceptions are serializable.Parallel Execution and CPS: Pitfalls and Patterns
The parallel step allows running stages concurrently. However, each branch runs in its own CPS context, which can lead to serialization issues if they share non-serializable objects.
Pattern:
``groovy parallel( branch1: { stage('Build A') { sh 'make a' } }, branch2: { stage('Build B') { sh 'make b' } } ) ``
Gotcha: If you define variables outside the parallel block and modify them inside, the changes won't be visible because each branch has isolated state. Use return to collect results:
``groovy def results = [:] parallel( a: { results.a = sh(script: 'echo result_a', returnStdout: true).trim() }, b: { results.b = sh(script: 'echo result_b', returnStdout: true).trim() } ) echo "Results: $results" ``
Serialization: The results map must be serializable. If you use a non-serializable object, parallel will fail.
Production insight: We once used parallel to run tests on multiple agents. One agent had a different environment that caused a non-serializable object to be captured in a closure, crashing the entire parallel block. We isolated the environment-specific logic into separate stages.
parallel with care, ensure shared data is serializable, and collect results via return values.Debugging with println vs echo and Script Console
In Jenkins pipelines, echo is the recommended way to print messages because it goes through the pipeline logger and is serializable. println writes to System.out, which may not be captured in the build log and can cause serialization issues if used inside a CPS method.
Rule: Always use echo in pipeline scripts. Use println only in Script Console or in @NonCPS methods (but even then, prefer echo via a helper).
Script Console debugging: You can test serialization of objects:
``groovy def testObj = [1,2,3] println testObj instanceof Serializable ``
Inspecting pipeline variables: If you have a pipeline that fails, you can attach the Script Console to a running build? Not directly, but you can inspect the build's CPS state after failure:
``groovy def job = Jenkins.instance.getItemByFullName('my-job') def build = job.getBuildByNumber(123) def exec = build.getExecution() if (exec) { println ``exec.serialize() }
Production insight: I once spent hours trying to debug a pipeline that printed nothing with println. Switching to echo revealed the issue immediately.
println. Switching to echo revealed the issue immediately.echo for logging in pipelines; use Script Console for interactive debugging.Common Groovy Pitfalls: GString, null, and Type Coercion
Groovy's dynamic nature can lead to subtle bugs in pipelines.
GString: "Hello $name" creates a GString object, which is serializable but can cause issues if you use it as a map key. Always convert to String if needed: "Hello $name".toString().
Null safety: Groovy's ?. operator (safe navigation) is safe in CPS. Use it to avoid null pointer exceptions:
``groovy def value = map?.key?.nested ``
Type coercion: Groovy automatically converts types, which can lead to unexpected behavior. For example, if (list) returns true if list is non-empty. Be explicit: if (list != null && !list.isEmpty()).
== vs equals: In Groovy, == calls by default. For identity comparison, use equals(). This is fine in CPS.is()
Production insight: We had a bug where a map key was a GString, and later lookup with a String key returned null. We fixed by always using .toString() on keys.
key.toString() when inserting into a map..toString() on keys.The JsonSlurper Serialization Meltdown
java.io.NotSerializableException: groovy.json.JsonSlurper at the line def json = new JsonSlurper().parseText(text)def result = sh(script: 'echo \'$JSON\' | jq -r \'.key\'', returnStdout: true).trim()- Any object that isn't serializable will cause CPS serialization errors.
- Use @NonCPS for pure data processing or avoid non-serializable classes entirely by delegating to shell tools.
java.io.NotSerializableException at a random lineprintln(Thread.currentThread().contextClassLoader.loadClass('org.jenkinsci.plugins.workflow.cps.CpsVmThread').current.getExecution().serialize())for loop throws groovy.lang.MissingMethodException: No signature of method: java.util.ArrayList.each()for (item in list) or list.each { } – avoid for (int i=0; i<list.size(); i++) style loops.Scripts not permitted to use method groovy.json.JsonSlurpersh 'jq ...' instead, or approve the method in Manage Jenkins → In-process Script Approval.println(Thread.currentThread().contextClassLoader.loadClass('org.jenkinsci.plugins.workflow.cps.CpsVmThread').current.getExecution().serialize())Check which variable is non-serializable by commenting out codePrint-friendly master reference covering all topics in this track.
Key takeaways
def for variables, for (item in list) for loops, and echo for logging.try/catch/finally and rethrow exceptions to fail the build.@NonCPS for pure data processing methods that don't call pipeline steps.JsonSlurper/XmlSlurpersh 'jq' or sh 'xmllint' instead.vars/ methods and @NonCPS helper classes in src/.retry and timeout built-in steps for robust error handling.Common mistakes to avoid
6 patternsUsing `for (int i=0; i<n; i++)` in a pipeline
for (item in list) or list.each { }Storing `Jenkins.getInstance()` in a variable
Using `println` instead of `echo`
println with echoAnnotating a method with @NonCPS that calls `sh`
sh call to a CPS method, keep @NonCPS for pure logicUsing `JsonSlurper` inside a pipeline
sh 'jq ...' to parse JSONNot pinning shared library version
library 'my-lib@v1.2.3' with a specific tagInterview Questions on This Topic
What is CPS transformation in Jenkins Pipeline?
Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Written from production experience, not tutorials.
That's Jenkins. Mark it forged?
9 min read · try the examples if you haven't