Engine Room

Never hash JSON.stringify for identity

My process launcher decided whether a running app was the one it knew by hashing its config. Reordering two keys in that config, with no change in meaning, made it kill a perfectly healthy process on the next boot.

My desktop launcher does a job on every boot called reconcile. It looks at the processes already running on the machine, compares them to the things it knows how to launch, and decides for each one whether it is mine and whether it is the version I expect. If yes, adopt it and show it in the library as running. If it is a leftover I no longer recognise, kill it, because that is usually a zombie squatting on a port after a crash.

To answer "is it the version I expect", every runnable, the small JSON manifest that describes how to launch a thing, got a fingerprint. I hashed the manifest and stored the hash next to the running process. On the next boot, if the manifest still hashed to the same value, the process was still the thing I meant. Simple, and wrong in a way that took a real scare to notice.

The fingerprint was sha1(JSON.stringify(manifest)).

The morning it killed a healthy app

I had edited a runnable file. Not the launch behaviour, just the shape. I reordered a couple of keys and let my formatter tidy it. Same fields, same values, same meaning. Then I restarted the Engine, and it reached into a perfectly healthy running child process and tree-killed it, on purpose, believing it was a stale orphan from a previous life.

It was not an orphan. It was exactly the app the manifest described. But the manifest text had changed, so the hash had changed, so the running process no longer matched any manifest the Engine recognised, so reconcile did the thing it does to strangers.

Why JSON.stringify lied

JSON.stringify serialises an object in the order its keys were inserted. {a:1,b:2} and {b:2,a:1} are the same object to anyone who cares about meaning, and two different strings to JSON.stringify. It also silently drops keys whose value is undefined, and it will produce different whitespace depending on how you call it.

So the hash was not a fingerprint of the manifest. It was a fingerprint of the exact bytes of one particular way of writing the manifest. I had encoded formatting into identity. The moment the formatting changed without the meaning changing, identity broke, and a boot-time garbage collector took that as licence to kill.

This is the trap with JSON.stringify as an identity function in general. Cache keys, dedup sets, "has this changed" checks, change detection in a UI: anywhere you turn an object into a string and treat the string as the object's name, key order is a landmine. Two callers who build the same object in a different order get two different names for one thing.

The fix is a canonical form

The fix is not to stop hashing. It is to hash a form of the object where equal meaning always produces equal bytes. I wrote a deterministic stringify: walk the object, sort keys at every level, keep array order because an array's order is part of its meaning, and drop undefined the same way every time. Hash that.

Now reordering keys in a runnable file produces an identical hash, because the sorter throws the order away before the hash ever sees it. A reformat is invisible to identity, which is what I wanted all along. The kernel has a test, one of twenty-one, that builds the same manifest two ways and asserts the hashes match.

The smaller rule

If you are hashing an object to answer "is this the same thing", do not hash JSON.stringify of it. Hash a canonical serialisation with sorted keys, or better, hash only the fields that actually mean identity and ignore the rest. A manifest's identity is its launch behaviour, not the order someone happened to type its keys or whether an editor added a trailing space.

The bug was quiet, which is the dangerous kind. Nothing threw. No error, no stack trace. A healthy process just vanished at boot because a config file got tidied, and the only symptom was an app that used to be running and now was not. I trust reconcile again, but only because identity finally means what I thought it meant.

← All devlog stories