Skip to main content

Which way were you facing?

· 6 min read
Massimo Di Stefano
FastGIS author

This was the week we stopped trusting the navigation and started measuring it. Real rides, real device logs, and a string of bugs that had been hiding in plain sight — one of them for three months.

The reroute never knew which way the rider was facing

The app has computed an anti-U-turn origin heading since early in the turn-by-turn work. It turns out that heading never once reached the routing engine. The server's validation model declared exactly lon and lat, and Pydantic silently dropped everything else the phone sent — heading, and the via type that marks a rejoin hint as a pass-through rather than a stop.

The symptoms were exactly what riders reported. With no heading, a reroute for someone travelling the wrong way comes back as "continue" — the instruction they are already following, repeated forever, with a perfectly good roundabout fifty metres ahead that the engine was never told was ahead. And with every via defaulting to a stop, one evening's log shows 47 multi-leg reroutes producing 69 phantom "you will reach the destination" announcements, mid-ride.

The docstring above that model warned that Pydantic ignores unknown fields. It had already cost us once. The lesson is now a house rule with teeth: test at the HTTP boundary, not the service — six of the eight new route-level tests fail against the old model.

Fixing the heading exposed the next layer. The rejoin logic guaranteed its target was forward along the route — which is the same as forward for the rider right up until the rider is travelling opposite to the route, which is precisely when they are lost. So the origin heading pointed at the roundabout while the rejoin hint pulled the line straight back through it, and the rider rode out and back. There is now a guard: a rejoin more than 120° off the rider's heading is dropped, and the reroute plans to the destination instead.

A week of riding the logs

The rest of the reliability sweep came straight off device logs from field rides. A guard-before-await race was leaking native location watchers — one ride ran with two GPS pipelines fanning fixes into an already-saturated thread, and one day's log showed 24 manager instances. "It stopped syncing with the GPS" was the app failing to consume fixes, not the phone failing to make them. A reroute storm — 52 reroutes in 20 minutes — turned out to be the throttle being disarmed by its own reroute. A rider who left the route stopped being told they'd arrived; a lost rider stranded by a non-convergence guard gets a time floor and an auto-retry — and then measurement showed that guard had shipped dead, its condition unable to fire until it had already fired.

When the marker can't be snapped to a road, it now draws a metre-true accuracy halo instead of a falsely precise dot. And then the best outcome of the week: the plan's next phase — snapping to the road we think you're on — set its own stop-condition in advance, two rides measured a 1 m median residual up to 91 km/h, and the phase is not being built. Deciding not to build something, on evidence, is a result.

Pick the places, then ride a circle through them

The scenic loop generator got turned inside out. Instead of "give me 120 km of loop and hope it passes something", you now pick the places — wiki-ranked villages and POIs by category and budget, curated on the map — and the optimizer computes the circle through them. Shipped on the phone and in the desktop planner the same day, and iterated twice on owner feedback before the weekend.

The plainer loop generator learned honesty too: when the road network can't deliver the distance you asked for, it says so — on the server, on the phone, and offline — instead of quietly handing you a shorter ride.

Tools that lie are worse than no tools

release-to-phones.sh had a bad week and came out much stronger. It could hang forever at a prompt when no phone was attached — one run was found still spinning fourteen hours after the build finished. It couldn't see a phone on a USB cable because it grepped for a status word only wireless prints. It once installed the build to the wrong phone and wrote a receipt saying the opposite. And it happily called an empty .app skeleton a successful build, because xcodebuild creates the folder before compiling anything.

Every one of those is fixed the same way: the script now checks ground truth — the debug-services probe, the device's product type, the Mach-O inside the bundle — instead of string-matching its way to optimism. A green exit is not a result. Same spirit on the code side: the mobile app finally has ESLint with rules-of-hooks as an error, wired into CI, days after a hooks-order crash took down every CarPlay routing flow in the field — a class of bug that could not have been linted for, because there was no linter.

Also

The CarPlay stability audit root-caused all three field symptoms — including that routing crash — and the usability round put route alternatives on the car screen with a companion picker. A desktop session no longer outlives its upstream token, and the B2B web app is walled off from rider accounts. Walk isochrones went from a 23-minute per-request projection to a ~3-second windowed crop — numerically identical output. The contour basemap was re-baked on the server: 8.85 GB → 5.3 GB, 102 warnings → 0. An OBD-II BLE dongle now has a transport and a diagnostic screen, parked until the motorcycle answers. The screen stays awake while navigating. And the half-ring that haunted signed-out phones after a group call is dead.

One more thing: the page you are reading lands on main this week. The docs site now ships with the repo — generated API reference with a drift gate so it cannot quietly fall behind the code, operations runbooks, and this blog.


Dig deeper: the rider app guide, and riding together for the CarPlay side.