Setting up clangd on Windows

clangd is a language server that allows other programs to access introspection and refactoring capabilities for C++ via the language server protocol. It was originally meant for code editors. For example, VS Code can use it via this extension, and provide functions like “Go to definition”, “rename”. With LLMs and their harnesses, they can give those capabilities to agents which otherwise navigate code-bases via the equivalent of Windows Notepad, e.g. by reading and grep’ing. For example, for pi.dev, there are these extensions that can use clangd.

How it works

clangd is usually run by the host application, which communicates with it by sending and receiving json-rpc fragments via stdin/stdout. It can either be kept running as a continuous service and serve from memory, or keep its preprocessed info persisted to disk and use that from per-request instances/processes.

Install clangd

Go to https://github.com/llvm/llvm-project/releases, and download the Windows installer for LLVM. At the time of this writing, the version is 23.1.2. Install that. By default, the executables will go into C:\Program Files\LLVM\bin.

Add that to your PATH, e.g. by pressing Win+R and running rundll32.exe sysdm.cpl,EditEnvironmentVariables. It should be sufficient to add it for your user.

Setup your project

Make sure the CMake variable CMAKE_EXPORT_COMPILE_COMMANDS is set to ON, which will generate a compile_commands.json in your build folder. This file contains the compiler invocations for your project, which clangd needs to correctly interpret your code.

Note that the CMake documentation specifically mentions that CMAKE_EXPORT_COMPILE_COMMANDS does not work well with unity builds. This is a shame, because they rock.

Drop a file called .clangd in your project root where to find that file. Since I usually use conan and their layout, my builds go in build/Debug or build/Release. Here, I can only pick one at a time:

  CompileFlags:
    CompilationDatabase: "build/Debug"

At this point, you should be able to use clangd on your code. There are many other things you can configure in that file, e.g. whether to store the persistent index I mentioned above.

Try it out!

The VS Code extension I mentioned at the top is a good way to check if everything is working as expected. Hovering over an identifier should give you some nice info about it:

Code snippet in C++ showing the declaration of a logger variable using the spdlog library and error handling in a try-catch block.

Give your workarounds a review date

A dependency has a bug. You investigate, find the offending code and add a small workaround. The application works again. There is a regression test, a comment explaining the problem and perhaps a link to an upstream issue.

That seems like a reasonable place to stop. Except for one small question: Who is going to remove the workaround?

Temporary code tends to stay

A comment saying “remove this when the library is fixed” relies on somebody reading it at the right moment. That moment may arrive six months later, during a dependency update performed by somebody who has never seen this part of the application. The workaround keeps running. The tests stay green. Nobody has a reason to look.

One way to make that reason appear is surprisingly simple: write a test that checks the dependency version.

A test that asks for a review

Suppose an application contains a compatibility patch for version 2.4.1 of a library. Alongside the tests for the patched behavior, add another assertion: the loaded library version must still be 2.4.1. Its failure message should explain the intended action:

The library version has changed. Check whether the compatibility patch is still necessary and compatible. Update the expected version only after this review.

When somebody updates the library to 2.4.2, this test fails – even if the application still works perfectly.

Usually, a test that fails after a harmless update would be a nuisance. Here, that interruption is its purpose. The version records the environment in which somebody last examined the workaround. Changing it means that this review is due again. This matters because a successful regression test can hide an obsolete patch.

Why green tests are not enough

A successful regression test can hide an obsolete patch. Imagine a library method that mishandles empty input. A local override corrects the behavior, and a test verifies the result. Later, the library fixes its implementation. If the override remains active, the regression test may continue exercising the local code. Everything stays green, but the application still owns a piece of code it could have deleted.

Worse, an override may bypass future improvements to the original method. A workaround that once restored compatibility can eventually become the thing that prevents it. The version check gives us a chance to notice.

Make the failure actionable

The failure message deserves some care. “Expected 2.4.1, got 2.4.2” provides very little help. A useful message identifies the patch, explains what needs checking and tells the reader what updating the expectation means. The next developer should be able to start the investigation without first reconstructing why the test exists.

Use the interruption selectively

There is a cost, of course. Exact version checks also interrupt updates that have nothing to do with the workaround. I would use them selectively for local modifications to third-party behavior, especially where internal APIs are involved. Applying them to every dependency would quickly create noise.

Conclusion

Temporary code has a habit of becoming permanent because it keeps working quietly. Giving it a test that eventually asks for attention is a small way to help it leave.

Modern python build and packaging

Developing software is not only about code but also about delivering the software to your customers. Sometimes, app stores are the solution, at other times it may be software-as-a-service (Saas) but there are also occasions where installers, compressed archives, system packages oder single file executables are (or fat-jars) are the way to go.

While python as a language and ecosystem has many points in its favour I personally found its build and delivery infrastructure lacking. As soon as you project requires a bunch of dependencies and users expect something like apt update to handle your software, too, things can get messy.

There were multiple options, tools and variants for the different aspects of software delivery:

  • setuptools vs. distutils
  • venvs, site-packages, pip, pipx, wheels
  • packaging tools like py2dsc-deb, bdist, pybuild
  • homebrew, anaconda etc.

For non-fulltime python developers like me there was always quite some doubt about the current idiomatic and preferred solution. In my C++ projects, this problem was largely solved by CMake+CPack. For java, maven and gradle provided easy solutions for many delivery and deployment tasks.

From my limited perspective the situation in the python world is more fluid and still in the process of finding a best-of-breed solution and evolving mostly that one option. Maybe we have arrived at that with the “Python Packaging Authority” and the work around pyproject.toml.

Many of our customers use Debian oder Debian-based distributions like Ubuntu and like having packages in their own apt-repository that they can install using system tools. So building deb-packages feels like the natural way to deliver your software to these customers.

Building Debian-Packages

In the past I used several approaches to packaging our python projects as debian packages:

  • “python setup.py sdist” in combination with plain dh_make and manual editing of the metadata files
  • pybuild/dh_python3
  • stdeb, with py2dsc etc.

Nowadays I am moving away from setup.py towards pyproject.toml. I find it quite readable and it provides neat features like executable scripts and single-sourcing the project version and a simple declarative project format. Together with debhelper and pybuild packaging becomes quite easy.

I create the necessary debian metadata-files, e.g. using dh_make or by hand.

The modified rules file may look as simple as:

#!/usr/bin/make -f

%:
	dh $@ --buildsystem=pybuild

# We do not have tests yet, so do not run pytest...
override_dh_auto_test:

My pyproject.toml may look like this:

[build-system]
requires = [
    "setuptools>=61.0.0",
    "wheel"
]
build-backend = "setuptools.build_meta"

[project]
name = "myproject"
dynamic = ["version"]
description = 'My project illustrates packaging'
readme = "README.md"
requires-python = ">=3.10"
maintainers = [
    {name = "Mihael Koep", email = "mihael.koep@softwareschneiderei.de"}
]

[project.scripts]
OmsMaxx = "myproject:start"

[project.urls]
Homepage = "https://gitlab.com/example/python/myproject"
Repository = "https://gitlab.com/example/python/myproject"
Issues = "https://gitlab.com/example/python/myproject/-/work_items"

[tool.setuptools.packages.find]
include = ["myproject"]

[tool.setuptools.dynamic]
version = { attr = "myproject.__version__" }

If the tools python3-build, python3-wheel, dh-python and pybuild-plugin-pyproject are available, building a working package should be as easy as running dpkg-buildpackage like below:

dpkg-buildpackage -b -us -uc

Conclusion

I hope this pybuild/pyproject.toml infrastructure with it extensibility and relative clarity will become (and stay) the de facto standard and improve over time.

This would end my uncertainty and provide a sensible default to shipping and packaging python projects.

Have you other approaches or opinions on this topic?

The Number You See Is Not Necessarily the Number You Get

There is a particular class of bugs that is easy to dismiss as a floating-point problem.

For example you have a value in Excel: 25604.88

You open the spreadsheet, and that is exactly what you see. You read the cell with a library, however, and suddenly you get something like:

25604.880000000001

At first this looks like a broken spreadsheet, a broken XML file, or a broken library.

It is none of those.

An .xlsx file is a ZIP archive containing XML files. If you inspect the worksheet XML, you may encounter a value along the lines of:

<c r="A1">
<v>25604.880000000001</v>
</c>

This is not Excel suddenly deciding that the user entered a different number.

The important distinction is that Excel displays numbers according to formatting and its rules for presenting floating-point values. The underlying numeric representation is based on binary floating point. Microsoft documents Excel’s use of IEEE 754 floating-point representation and the resulting precision limitations.

This distinction is worth understanding because it can turn a seemingly harmless value into a surprisingly difficult integration bug.

Why can’t a computer just store 25604.88?

Because computers don’t normally store floating-point numbers as decimal fractions.

A typical double uses the IEEE 754 binary64 representation. Conceptually, the value is represented using a sign, an exponent and a significand. There are 64 bits available in total, with 53 bits of precision in the significand.

This works beautifully for binary fractions.

For example:

0.5 = 1/2 = 0.1b
0.25 = 1/4 = 0.01b
0.75 = 1/2 + 1/4 = 0.11b
0.125 = 1/8 = 0.001b

All of these can be represented exactly in binary.

How to convert with simple Operations:

0.625 * 2 = 1.25 => 1
0.25 * 2 = 0.5 => 0
0.5 * 2 = 1 => 1
=> 0.101b

But other fractions can not be represented by binary fractions exactly like 0.1.

0.1 * 2 = 0,2 => 0
0.2 * 2 = 0.4 => 0
0.4 * 2 = 0.8 => 0
0.8 * 2 = 1.6 => 1
0.6 * 2 = 1.2 => 1
0.2 * 2 = 0.4 => 0 // REPEAT
0.4 * 2 = 0.8 => 0
0.8 * 2 = 1.6 => 1
0.6 * 2 = 1.2 => 1
=> 0.00011001100110011001100110011...b

It is a unending number, limited by memory space for storing the number. When translating back to decimal is won’t be exactly 0.1 again.

0.00011001100110011001100110011b = 0.09999999962747097015d

Or for our example with 25604.88 the decimals 88 will be convert like this:

0.88 * 2 = 1.76 => 1
0.76 * 2 = 1.52 => 1
0.52 * 2 = 1.04 => 1
0.04 * 2 = 0.08 => 0
0.08 * 2 = 0.16 => 0
0.16 * 2 = 0.32 => 0
0.32 * 2 = 0.64 => 0
0.64 * 2 = 1.28 => 1
0.28 * 2 = 0.56 => 0
0.56 * 2 = 1.12 => 1
0.12 * 2 = 0.24 => 0
0.24 * 2 = 0.48 => 0
0.48 * 2 = 0.96 => 0
0.96 * 2 = 1.92 => 1
0.92 * 2 = 1.84 => 1
0.84 * 2 = 1.68 => 1
0.68 * 2 = 1.36 => 1
0.36 * 2 = 0.72 => 0
0.72 * 2 = 1.44 => 1
0.44 * 2 = 0.88 => 0 // REPEAT
=> 0.11100001010001111010...b

What does this mean for programmers?

The important lesson is we need to know what we are dealing with.

When reading numbers from external sources such as Excel, JSON or a database, don’t assume that the value you see is exactly the value your program receives. Check the actual value and the type your library produces.

For many calculations, this is completely fine. But when exact decimal values matter — for example, with money, accounting or other business rules — don’t leave this to chance.

Use a representation that matches the requirement. For example use BigDecimal or BigFraction for exacter decimal values.

You heard it here first: Stay clear of Always-Updating Software

Depending on who you ask, I would be described as one of the most laid-back, laissez-faire people around, or a dangerously megalomaniac, unrealiable weirdo [Author’s Note: this would lead us astray], and so in terms of what-software-to-use, I would consider most people as the world-leading expert in what works best for them personally.

However, one pattern seems to grind my gears, and while I don’t feel that the Schneide Blog is the best place for overbearing rants based on minor inconveniences, I still recognize a pattern that might be worrying because it is, at least, getting more and more common.

There is some software that is ALWAYS, whenever you start it, surprising you with a software update. Sometimes it is forced upon you, sometimes with sleazy sleights of hand, sometimes you even have the right to have a say in that matter, but nevertheless, it’s ALWAYS. Might not be numerical-always, but still noone on Polymarket would bet against you, so, it’s ALWAYS.

In case you would’t know what software I am talking about – I can give you several names from the top of my head – but you probably know lots of examples on your own 🙂

My core message is, that currently, these scream: “I will bring you harm!”

What is going on with software, having a need to constantly disturb my workflow – a thing that is holy for any serious software developer?
(a) maybe the border-radius of the third panel in the About… dialog was slightly off, and the application shutdown could be improved about 200-300µs.
(b) they are now relying purely on AI to write their most critical core features, with no sensitivity for security whatsoever.

And while I can totally understand (a), in current times you should be honest to endorse – unless you know better – it must be (b).

I, myself, am sometimes willing to recognize that I am not the most risk-averse human in the history of the world, maybe ever; and you probably really are more of an expert of what is good for you than my opinion would count; so, I also use some of these regularly. So, do your own what-could-possibly-go-wrong-gambles, but it is our job to at least feel smart about it when shit is going down (in hindsight, naturally).

Point is, I _could_ be bothered to check the release notes of any of these before any update. But because their move is “we demand on this urgency”, and my thought is “I’m in the process of saving my customer’s life (again)”, I will rarely do that, and unless they communicate better, I think there needs to be a rightful place to call these dangerously megalomaniac, unreliable weirdos out.

Docker in Continuous Integration

Docker – or more general containerization – can be applied in several areas of software development to improve many aspects of our work. The most common are:

  • In development to easily setup an environment for running and developing the software without installing every manually on the target OS.
  • For deployment either in docker-stacks (e.g. managed by portainer) or kubernetes only requiring hosts able to run containers without specialized setups.
  • For building software artifacts on your continuous integration (CI) infrastructure with the same benefits as the former two applications.

In essence you trade snowflaking all the involved machines (dev, CI nodes, servers) for some additional complexity around Dockerfiles, images, containers and their orchestration.

In this post I want to focus on the CI part:

Using Docker in CI

Administrators and infrastructure people usually will cheer if they just need to provide docker-capable nodes for developers to build their projects on. No need to provide certain runtimes, libraries, tools and configurations anymore. No need to negotiate with developers about the environment all the time and keeping it updated, appropriate and secure.

Developers on the other hand need to work with another tool encapsulating their build process. While this gives them a lot of freedom in choosing and shaping the environment for their builds (the “inside” of the containers) it adds pitfalls and complexity on the outside.

If your artifact is not (only) another docker image but things like test- and coverage reports, binaries or other files you have to move stuff from inside the containers to the outside aka host. In CI use cases you have essentially the following alternatives, each with different pros and cons:

Bind mounts for a container run

In this approach you build your environment using a normal docker build command and follow it with a docker run, e.g.:

docker build -t build-project .
docker run --rm -u `id -u` -v `pwd`:/build build-project

Pros:

  • This approach feels natural and does automatic container cleanup by using the --rm option on docker run.
  • It also enables dependency caches etc. on the host which can reduce build times, even across projects.
  • Sometimes you do not even need a dockerfile but can use plain docker images without building one yourself, e.g. eclipse-temurin:21-jdk.

Cons:

  • It actually runs a container and has limited file system access to the host.
  • It may leave build artifacts and temporary files on the host.
  • It may create file ownership issues, hence the -u `id -u` arguments to docker run in the example.

Image build and container to copy from

This approach is similar to above in that is consists of several steps but it does not need mounts and does most of the stuff during docker build:

set -eu
docker build -t build-project .
container_id=$(docker create build-project)
trap 'docker rm -f "$container_id" >/dev/null 2>&1 || true' EXIT
docker start $container_id
docker cp $container_id:/buildresults/ ./artifacts/

Pros:

  • Most stuff happens inside docker build.
  • You can use layer caching to improve build times.
  • Cleanup is done using shell mechanismns (trap), can also be performed using different means.
  • What leaves the container is exactly and explicitly controllable

Cons:

  • You need to run a container
  • You need to take care of container cleanup
  • Full benefit of layer caching may require thought/engineering to improve build times

Multi-stage build with scratch-image output

In this approach we never actually run a container and let docker build copy the specified artifacts to the host using a multi-stage build. Most of the interesting bits are in the Dockerfile while the build call looks like:

docker build --output artifacts/ .

The Dockerfile gets a bit more complex:

FROM python:3.14 AS build
WORKDIR /build

COPY . .

RUN pip3 install --no-cache-dir -r requirements.txt
RUN python3 -m build

FROM scratch AS package
COPY --from=build /build/dist/*.whl .

Pros:

  • No need to explicitly cleanup containers or dependency caches
  • Layer caching possible
  • No need to run a container explicitly, building the image is enough
  • Only one simple call in the build pipeline

Cons:

  • More complexity inside the Dockerfile

Conclusion

Using docker for building software has many advantages with the price of an additional tools and its own complexities. Several, easily adaptable approaches exist to facilitate containerization in CI environments. Use the one that fits your requirements and environment best.

Which approach do you like best? What are you using in your build and delivery pipelines?

I would be glad to hear your thoughts and comments.

Dynamic Device Classes in Python

When writing PyTango device servers, it is common to implement one Python class per Tango device class. For small projects, this approach is simple and easy to understand. However, it becomes cumbersome when the set of available devices is not known at development time.

Consider a device server that should be entirely driven by a configuration file. Instead of hard-coding every supported device class, the server reads the device definitions at startup and creates the required Tango device classes automatically.

The goal is to write a generic PyTango server that does not need to be modified whenever a new device class is added. If a new entry appears in the configuration file, the server should simply create the corresponding Tango device class.

Python type

At first glance, this sounds unusual. After all, Python classes are typically defined using the familiar class keyword:

class Motor(Device):
    pass

Most Python developers stop here and never think about how classes are actually created. Under the hood, however, classes are objects themselves, and Python provides a built-in mechanism to construct them dynamically.

The function responsible for this is type().

Most of us use it in its simplest form to inspect the type of an object:

print(type(42))
# <class 'int'>

Less well known is its three-argument form:

type(name, bases, attributes)

The arguments are:

  • name: the name of the new class.
  • bases: a tuple of parent classes.
  • attributes: a dictionary containing class attributes and methods.

This means that the following definition is equivalent to the above class definition.

Motor = type(
    "Motor",
    (Device,),
    {},
)

The resulting object is exactly the same: a Python class that can be instantiated or registered with PyTango.

The third argument of type() becomes particularly interesting when more than just the class name should be configurable. The attributes dictionary allows methods, properties, or other class members to be added dynamically. This can be useful when Tango attributes or commands are also described in the configuration file.

Motor = type(
    "Motor",
    (Device,),
    {
        "some_property": 42,
    },
)

a = Motor()
print(a.some_property)
# 42

Conclusion

For our use case, creating classes dynamically becomes straightforward. Reading the configuration file and creating the required classes can be done in a simple loop. From PyTango’s perspective, there is no difference between a statically defined class and one created dynamically using type(). Both behave like ordinary Python classes.

AI Code Won’t Be for Humans Much Longer (AI impressions, part 2 of 5)

This is the second part of the series “Impressions of Our Current AI Usage”, as outlined by the introduction article.

In the first years of software development, the word “source code” didn’t exist, because code was just that: encoded machine instructions. How they were encoded changed rapidely, from flipping bits in the RAM directly by mechanical switches over feeding paper tapes with punched holes to magnetic storage. But for a long time, we worked with none or little abstraction over the actual machine code. I remember assembler code listings that had two columns of text: the first column for the computer, the second one just translating the first column into human-readable text.
And even with this little bit of clarification what the code actually does, we already needed additional software that took our source code and translated it for the machine.
With the adoption of higher-level programming languages, the additional software stack grew in depth until the distance between the source code and the actual machine instructions was big enough to warrant an intermediate layer of representation. Programming languages like Java or C# put a “byte-code layer” between our textual source code and the binary machine code. The machine we program against is no longer a real computer, but a “virtual machine” or in better words, a model of a machine. As long as we write source code that works correct with the model, we can assume that all the translation layers will find a way to run it correctly on the real computing substrate.
We are used to this kind of programming. We describe our goals using the machine model and a sophisticated machinery of software and hardware parts make it happen.

Forward to today and we use artificial intelligence (or inference using another kind of “model”) to produce source code in our favorite programming languages by describing our goals in even broader terms than before. We might mistake our prompts for natural language and think that we are able to produce source code by just saying what we want.

The question that poses itself nearly instantly is: If we invented a device that transforms natural language into machine behaviour from scratch today, would we include all the intermediate layers into its inner works? Is it really a good idea to transform natural language into higher-level source code, compile the source code into byte code and do all the weird magic to come up with a sequence of machine instructions that resemble the byte code? Isn’t it more efficient to teach the inference how the actual machine works and let it program directly?
Or, asking from the other side, who is the target audience of the generated source code if nobody reads it and the compiler only parses it once? Why does the inference invent all the variable and method names when the compiler throws them away again in the first step of its processing? Of course, right now the inference only imitates our way of working. But we work like we do because we write source code for other humans. If the human at the helm can’t read any layer of code anyway, why not jump directly to the most obscure representation of code and skip all the readability requirements?

As soon as the inference doesn’t imitate but actually learns about the target computing substrate, it will produce working code that is undecipherable for human readers, but optimal for the machine. (If you want to experience this effect in a tiny dose, I encourage you to play the “TIS-100” programming game). And because most inference users don’t need the readable code anyway, they won’t miss anything and get faster solutions with less hassle.

So my guess is that today’s source code will be a dying art, invented for humans and ignored by the machines because it doesn’t provide anything useful for them. The source code of the future will be less readable, more enigmatic and probably more efficient for the machine. Which means that human intervention or even just participation in the software production process will become more cumbersome and therefore even more limited.

My sorrow is that this distancing of the programming process from actual human oversight might provide a hard depedency on inference work alone. It would mean that humans aren’t just scales slower than the inference, but actually incapable of doing its work by hand anymore.

Quotes Are Not Part of the Argument

Recently, we upgraded a project from an older Java version to a newer one. Many of the changes were routine: update dependencies, replace deprecated APIs, fix a few compiler errors and run the test suite.

One of these changes concerned the invocation of an external Windows program.

The application used the deprecated overload:

Runtime.getRuntime().exec(command);

The command was assembled as one long string. The external program accepted command-line parameters of the following form:

/a="value of A" /b="value of B" /c="value of C"

Because the parameter values could contain spaces, we enclosed them in double quotes. We even had unit tests that explicitly verified the quoting. It was an important detail, or so we thought.

As part of the upgrade, we switched to the recommended overload that accepts the executable and its arguments separately:

Runtime.getRuntime().exec(commandArray);

The migration seemed straightforward. Instead of joining the executable and all parameters into one command string, we put them into a string array:

String[] command = {
"program",
"/a=\"value of A\"",
"/b=\"value of B\"",
"/c=\"value of C\""
};
Runtime.getRuntime().exec(command);

The unit tests were still green. The quotes were still present. Everything looked correct.

Then the application was deployed.

Invalid switch

In production, the external program stopped accepting our invocation. Its only diagnostic message was:

Invalid switch

This was not particularly helpful.

We inspected the parameters. All switches were present. Their spelling was correct. Their order was correct. The values were correct. The quote characters were exactly where our tests expected them to be.

Even more confusingly, the command worked perfectly when entered manually in a Windows command prompt:

program /a="value of A" /b="value of B" /c="value of C"

The executable clearly supported these parameters. The shell command clearly worked. And our Java code appeared to produce the same command.

But it did not.

Asking the receiving program

After spending some time comparing strings and staring at quote characters, we decided to stop reasoning about what the external program ought to receive. Instead, we wrote a small program that showed us what it actually received:

void main(String[] args) {
for (var i = 0; i < args.length; i++) {
System.out.println("args[" + i + "]: '" + args[i] + "'");
}
}

We packaged it as a JAR and invoked it from cmd.exe using the same parameter structure:

java -jar exec-experiment.jar /a="value of A" /b="value of B" /c="value of C"

The output was:

args[0]: '/a=value of A'
args[1]: '/b=value of B'
args[2]: '/c=value of C'

The double quotes were gone.

This was the missing piece.

The quotes in a shell command are not necessarily characters intended for the receiving program. They are instructions to the command-line parser. They tell it that a sequence containing spaces belongs to one argument.

The value

/a="value of A"

does not mean that the program receives an argument containing two quote characters. It means that the program receives one argument rather than three:

/a=value of A

The quote characters control parsing. They are not part of the resulting argument.

Quotes can appear in surprising places

To verify this interpretation, we performed a slightly more unusual experiment:

java -jar exec-experiment.jar /"a="va"lue of A" /b="value of B" /c="value of C"

This command is certainly not how anybody would normally write the parameters. Nevertheless, its output was unchanged:

args[0]: '/a=value of A'
args[1]: '/b=value of B'
args[2]: '/c=value of C'

The quotes can surround different portions of a token. Their purpose is to influence how the command line is divided into arguments. Once parsing is complete, they disappear.

This distinction is easy to overlook because a command line is usually presented as a string. It looks as though this string is passed to the program. In reality, there are two different representations involved:

program /a="value of A"

is a textual command line that still needs to be parsed.

By contrast,

new String[] {
"program",
"/a=value of A"
}

already describes the result of that parsing: an executable followed by one complete argument.

We had moved the parsing boundary

With Runtime.exec(String[]), every array element already represents one argument. Spaces inside an element do not split it into additional arguments.

By retaining the quotes, we had changed their meaning. They were no longer syntax interpreted by a shell-like parser. They had become literal characters inside the argument:

/a="value of A"

That was not what the external program expected. It expected:

/a=value of A

The error message “Invalid switch” was therefore accurate, but not very illuminating. The switch looked correct in our logs because we were looking at its command-line representation rather than at the argument format expected by the program.

The fix was simple:

String[] command = {
"program",
"/a=value of A",
"/b=value of B",
"/c=value of C"
};
Runtime.getRuntime().exec(command);

Or, preferably, using ProcessBuilder:

Process process = new ProcessBuilder(
"program",
"/a=value of A",
"/b=value of B",
"/c=value of C"
).start();

After removing the quote characters, the external program worked again.

Ironically, the quotes that our old implementation and its tests had treated as essential were exactly what broke the new implementation.

The takeaway

A command line and an argument array are not interchangeable representations.

When constructing a command line, quoting may be required to preserve spaces during parsing. When constructing an argument array, parsing has already happened conceptually. Each element is one argument, spaces included.

Do not ask:

How would I type this command in a shell?

Ask:

What exact strings should the receiving program find in its argument array?

The answer to the second question is what belongs in Runtime.exec(String[]) or ProcessBuilder.

Sometimes an API migration changes more than a method signature. It moves a boundary – in this case, the boundary between formatting a command line and supplying already separated arguments.

And when that boundary moves, yesterday’s carefully tested solution can become today’s bug.

Great software engineers are transformers

Created by me; using https://www.deviantart.com/dreamup

…or transformators. Often when I meet people and talk to them about their jobs or everyday life topics come up about suboptimal processes and workflows and other complexeties we have to deal with. Almost everytime such issues arise my brain start a background process working on ways to improve the talked-about situation.

Many of my developer colleagues and friends are the same: We all shake our heads or face palm and immediately think about possible remedies.

Talking to non-developers about the same issues often leads to reactions like

  • It cannot be changed
  • It has been this way since forever
  • We have never done it that way

None of the great (software) engineers I had the pleasure to work with thinks this way:

They all try to understand the context, domain and status quo. As a part of this process often the first problems are uncovered. In addition we define aims and target metrics together with the domain experts who often are part of one of several user groups.

On that basis they develop solutions fitting to the situation at hand. All the constraints in time, resources and knowledge are taken into account. Knowing that the initial scope usually does not cover a full solution, an evolvable system is designed that already provides value. Over time they add features, fix blind spots and weaknesses and gradually expand the scope.

This analytic view and the incremental process towards a system that improves the current situation is key in pushing things forward. It also eliminates most of the “impossible to implement/change” counterargument.

The latter two main arguments against change revolve often about excluding user groups like elderly people or people accustomed to the status quo unwilling to adapt. They can be mitigated by designing the systems and services to have multi-modal inputs and outputs. Working with them can stay largely unchanged for inert users groups while others may utilize the new options the solutions offer.

Recently, I heard of a nice example of this: Customer banking already is largely digitalized but there are banks that still offer inboxes for credit transfer on paper in addition to online-banking and apps. People without digital devices or knowledge simply throw the papers into the inbox where it automatically gets scanned and digitally processed. And there is still the option to snail-mail the account statements for the people who do their management on paper. All the process in between is automated and digital leaving no one behind.

In https://schneide.blog/2026/01/19/digitalization-is-hard-especially-in-germany/ I described general guidelines to make mostly analog real-world processes digital and frictionless.