Blog ·
Write now, lock later
Every piece of Memories, the card, the magnet, the badge, ships with an NFC chip inside it. Writing that chip is one phone (a Chrome for Android build, because iPhones cannot write tags from the web at all) held to the back of each piece at a bench. Two operations per chip: write the URL, then lock it read-only, so nobody can quietly repoint a piece sitting on somebody’s fridge. Simple enough to write in one function and forget about.
The first version
const reader = new Reader();
await reader.write({ records: [{ recordType: "url", data: url }] });
await reader.makeReadOnly();
Two tag operations rather than one, chained in the same function, same
NDEFReader instance, same tap. Reads fine. Nothing about it looks wrong in
review, and the comment above it at the time said the only catch was that
makeReadOnly() needs the card still held there between the two calls.
At the bench, on the actual fulfilment phone, it took down the tab. Not an error, not a rejected promise, the whole renderer died mid-batch, the console stopped at “written, locking” and never printed again. Whatever was left of that run, unwritten cards in a stack of fifty, had to be found by hand.
The fix that looked right
The working theory: makeReadOnly() fired as a second raw operation into the
NFC session the write was still holding, and that sequence is what Chrome’s
NFC stack couldn’t survive. So, tear the session down between the two calls:
lift the card, ask for it again, lock on a fresh reader.
console.log("nfc: written, waiting for the second tap to lock");
say("Written. Lift the card off, then tap it again to lock it.");
await step(
makeReader().makeReadOnly({ signal: AbortSignal.timeout(NFC_TIMEOUT_MS) }),
);
One extra tap per piece, a fresh NDEFReader() for the lock call instead of
reusing the one from the write. The card comes off the antenna, Android tears
the session down, the lock starts clean. I committed that as the fix.
Wrong direction
I never ran it. Three minutes later, reading it back before taking it to the bench, the problem with it was the part I hadn’t written down: I had a theory about why the crash happened, and I’d built the fix entirely out of the theory. If the theory was wrong, and I had no way to check it, since a native crash inside Chrome’s NFC stack is not something I can reproduce in a test, then the fix was worth nothing.
And the shape of it was still bad even if the theory was right. Locking still
lived inside the same per-piece flow as writing. A crash on piece 30’s lock
step would kill the tab that pieces 1 through 29 were written from, and there
is no AbortSignal for a native call that has already wedged the renderer. I
had made the crash need one more tap to arrive at. I had not made it cost any
less when it did.
So the question changed. Not “how do I stop makeReadOnly() from crashing
Chrome”, which I couldn’t answer and couldn’t test, but: what does the crash
cost when it happens anyway. That one I could answer without knowing the
cause at all.
Two passes, not one function
Writing and locking split into two separate stations. The encoder writes a whole stack and marks each order encoded as it goes, no lock call anywhere in that path:
export async function writeTag(url: string, say: (m: string) => void) {
say("Hold the card to the back of the phone, high up near the cameras.");
await step(
makeReader().write(
{ records: [{ recordType: "url", data: url }] },
{ signal: AbortSignal.timeout(NFC_TIMEOUT_MS) },
),
);
}
Locking is a second screen entirely, run after the whole stack is written, over pieces that are already sitting correctly on the bench:
export async function lockTag(say: (m: string) => void) {
say("Hold the piece to the back of the phone to lock it.");
await step(
makeReader().makeReadOnly({ signal: AbortSignal.timeout(NFC_TIMEOUT_MS) }),
);
}
Now a crash at the lock station costs the lock station. Every write already
landed and is already recorded server-side, so the tab dying there loses
nothing, reopen it and keep locking where it left off. And if this phone
never survives makeReadOnly() at all, the URL is already correctly on every
card either way, so locking can finish with any NFC app that has a
write-protect button. The crash didn’t go away. It stopped being able to take
anything down with it.
The other bug in the same file
One more, smaller, found on the same night: write() resolves when a tag
comes into range and waits forever if one never does. No deadline meant the
operator grants the permission, the promise parks, and the screen just stops,
which reads exactly like a frozen phone rather than a phone that is correctly
waiting. AbortSignal.timeout() on every call and a message that says where
the antenna actually sits fixed it:
if (error instanceof DOMException && error.name === "TimeoutError")
throw new Error(
`No card in ${NFC_TIMEOUT_MS / 1000}s. The antenna is high on the ` +
`back, near the cameras, not the middle.`,
);
Neither bug was visible in review. Both are pinned in a test now, not because
the crash can be reproduced there, it can’t, but because the shape that
contains it can: a test asserts that writeTag never calls makeReadOnly in
either direction, so the next person who wants to save an operator a tap
can’t quietly put the two back in the same function.
What I took from it
The first fix and the second used exactly the same evidence and pointed in opposite directions, three minutes apart, and the difference between them was not new information. The first was built out of a guess about why the crash happened. It might even have been the right guess, I still don’t know, and that is the point: there was no way to find out, so everything resting on it was resting on nothing.
The second fix needed no theory at all. It treats the crash as a fact about this phone that isn’t going anywhere and asks only what it should be allowed to cost. That question has an answer you can check. Once locking couldn’t take a write down with it, the crash stopped being an incident and became a line in a runbook: reopen the lock station, carry on.
When a failure can’t be reproduced, a fix aimed at its cause is a bet on an explanation you can’t test. Aim at the blast radius instead. That one you can verify on a good day, with no crash in sight.