Skip to main content

3 posts tagged with "JSON"

View all tags

Go Fyne Desktop Pitfalls: JSON Shadowing & SSH Fingerprints

ยท 9 min read

While delivering a Go + Fyne desktop login tool for a client, we hit one pitfall in each phase: config parsing, SSH handshake, cross-platform packaging, and GUI verification. All five are solved, and every fix is reusable.

The tool was built for the fully managed China hosting engagement for an industry-leading manufacturer โ€” after the WHM entrance was upgraded from a single password to layered gates, the team needed a way to log in without ever touching the root password. This small tool is the client side of that channel: click one button, get a one-time link into WHM. Small tool, but "small" does not mean pitfall-free.

TL;DRโ€‹

ScenarioRoot causeFix
SSH fingerprint check always failsssh-keygen prints unpadded base64; Go StdEncoding padsNormalize both sides (strip =, unify prefix)
Explicit timeout parses to 0Embedded struct field shadowed by outer same-name fieldUnmarshal the same data twice
fyne-cross: go.mod requires go >= 1.26.0Container toolchain old, GOTOOLCHAIN=local-env GOTOOLCHAIN=auto
Goroutine UI updates not guaranteedStrict threading model is opt-in in 2.6โ€“2.8fyneDo=true + -tags migrated_fynedo
GL window screenshots come out black in WSLgGL direct rendering bypasses X11 captureVerify via server-side log counts

Scenario 1: SSH fingerprint check always fails? Look at the trailing equals signโ€‹

The tool's first security layer pins the host key: the client only trusts the ed25519 fingerprint hard-coded in its config, so a hijacked machine can't intercept the one-time link. The fingerprint comes from ssh-keyscan on the server side.

The first live test failed every connection with host key fingerprint mismatch. Only by placing expected and actual values side by side was the difference visible โ€” a single trailing equals sign:

ssh-keygen -lf  โ†’  SHA256:tT5rFtRWhCcsvJg58hNOXt0rqYvSPmur6pL8xE2KxQ   (43 chars, no padding)
Go StdEncoding โ†’ tT5rFtRWhCcsvJg58hNOXt0rqYvSPmur6pL8xE2KxQ= (44 chars, one =)

The root cause is an encoding spec detail: an ed25519 public key is 32 bytes, which base64-encodes to 44 characters, the last one being padding. ssh-keygen -lf strips the padding and prints 43 characters; Go's base64.StdEncoding pads by standard. Both are "correct" โ€” stacked together, they never match.

Normalize both sides before comparing โ€” strip trailing padding, unify the SHA256: prefix:

// Normalize a SHA256 fingerprint: strip padding, unify prefix, validate on the way
func normalizeFingerprint(fp string) (string, error) {
fp = strings.TrimPrefix(fp, "SHA256:")
fp = strings.TrimRight(fp, "=")
if _, err := base64.RawStdEncoding.DecodeString(fp); err != nil {
return "", fmt.Errorf("invalid sha256 fingerprint: %w", err)
}
return "SHA256:" + fp, nil
}

After normalizing, a unit test covers both spellings in, one spelling out, and the four live-test paths โ€” valid login, wrong fingerprint, unauthorized key, black-hole timeout โ€” all behave as expected.

If you'd rather not hand-roll it: x/crypto's ssh.FingerprintSHA256 already returns the unpadded form, matching ssh-keygen. The trap isn't in the library โ€” it's in rolling your own encoding on one side only.

Note

x/crypto's ssh.HandshakeError has no Unwrap method โ€” typed errors you return from the handshake callback (like a fingerprint mismatch) can't be recovered with errors.As once dial wraps them. Match the error text with a regex, extract both fingerprints, and rebuild the typed error so the UI can show "expected X, got Y". Found during v1.1 error-advice work.

Scenario 2: Explicit timeout parses to 0? Embedded struct fields get shadowedโ€‹

The config struct looked like this: most fields live in a general Config, and timeouts wanted "default when absent" handling, so they were declared as outer pointer fields alongside the embedded struct:

type Options struct {
Config // embedded: host, port, timeouts...
ConnectTimeout *int `json:"connect_timeout_seconds"`
CommandTimeout *int `json:"command_timeout_seconds"`
}

The intent: pointer fields detect "did the user set this", and defaults fill in when nil. The live test instead reported command timed out after 0s โ€” the timeout value explicitly present in config.json parsed to 0.

The root cause is encoding/json's conflict rule: when multiple fields map to the same JSON key at different depths, the shallower (outer) one wins and the embedded struct's same-name field is never populated. So Options.Config kept zero-value timeouts, while the outer pointer did receive the explicit value โ€” but the code only ever touched it in the "nil means default" branch. The explicit value landed in a field nobody read back. Silent loss.

The fix: stop trying to parse and probe defaults in one layer. Unmarshal the same data twice, each pass doing one job:

type Config struct {
Host string `json:"host"`
Port int `json:"port"`
ConnectTimeout int `json:"connect_timeout_seconds"`
CommandTimeout int `json:"command_timeout_seconds"`
}

type timeoutOverrides struct {
Connect *int `json:"connect_timeout_seconds"`
Command *int `json:"command_timeout_seconds"`
}

func load(raw []byte) (*Config, error) {
cfg := &Config{ConnectTimeout: 30, CommandTimeout: 15} // defaults
if err := json.Unmarshal(raw, cfg); err != nil {
return nil, err
}
var ov timeoutOverrides
if err := json.Unmarshal(raw, &ov); err != nil {
return nil, err
}
if ov.Connect != nil {
cfg.ConnectTimeout = *ov.Connect
}
if ov.Command != nil {
cfg.CommandTimeout = *ov.Command
}
return cfg, nil
}

Pass one fills the plain fields into Config; pass two probes absence with pointer-only fields and overrides defaults only when a value is explicit. A regression test asserts "explicit values survive" โ€” this class of bug is silent by nature, and without a test watching it, the next refactor will bring it back.

JSON traps aren't limited to parsing. On the serialization side we previously hit a sneakier one: json.dumps with default=str silently turns a Python set into a string, and the in check then quietly returns wrong answers. The common thread: both happen where the type system can't see.

Scenario 3: fyne-cross reports go.mod requires go >= 1.26.0? The container toolchain is lockedโ€‹

The Windows build goes through fyne-cross, and packaging failed with go.mod requires go >= 1.26.0. The container ships go 1.25.10 while x/crypto v0.57.0 demands go 1.26+ โ€” and the container defaults to GOTOOLCHAIN=local, which disables Go 1.21's toolchain auto-switching. Whatever version ships in the image is what you're stuck with.

One-line fix โ€” let the container fetch the toolchain it needs:

fyne-cross windows -tags migrated_fynedo -env GOTOOLCHAIN=auto

GOTOOLCHAIN=auto lets the go command download and switch to the version required by go.mod automatically. Future dependency bumps won't require touching the container. The flag is now baked into the project's build.sh.

Scenario 4: Goroutine UI updates not guaranteed? Threading is opt-in until 2.9โ€‹

Fyne 2.6 introduced a strict threading model where UI updates must go through fyne.Do. The easy-to-miss part: in v2.6โ€“2.8 neither the model nor fyne.Do is enabled by default. Without explicitly opting in, mutating UI widgets from a background goroutine has no guaranteed behavior; the default flips only in v2.9.

This tool runs SSH handshakes and link fetches in background goroutines and then updates a status line โ€” right inside that window. Enable it with a belt-and-suspenders pair:

# FyneApp.toml
[Migrations]
fyneDo = true
# plus the build tag
go build -tags migrated_fynedo .

One switch is runtime config, the other is compile-time; either alone is enough, and setting both guards against updating only one. On the code side, make it a habit:

go func() {
link, err := fetchOneTimeLink()
fyne.Do(func() {
if err != nil {
status.SetText("Failed: " + err.Error())
return
}
status.SetText(link)
})
}()

After upgrading to v2.9, none of this needs to change, and the migration markers can stay.

Scenario 5: WSLg screenshots of Fyne windows come out black? Verify with server-side evidence insteadโ€‹

The acceptance criterion was "double-click the program, press the button, the browser opens the WHM login page." For pixel-level GUI verification we found that under WSLg, screenshotting a Fyne (OpenGL) window with scrot or import yields pure black frames โ€” GL windows render outside the X11 pixel-capture path, so conventional screenshot tools get nothing.

There is no "fix" for this one โ€” it's a workaround, stated as such:

  • Keyboard events do get through: xdotool sending Tab + Return successfully activated the button, so GUI automation on the input side works;
  • XTest mouse clicks did not work โ€” don't burn time on them;
  • Pixel-level verification is abandoned in favor of server-side evidence: the button triggers a privileged whmapi1 call on the server, so comparing the sudo log count before and after the click (21 โ†’ 22 in our run) proves "the GUI really drove the whole chain" โ€” no screen pixels required.

For tools where the GUI is just a trigger and the real action happens server-side, a server log count is actually stronger evidence than a screenshot: it proves behavior, while a screenshot only proves appearance.

FAQโ€‹

Why is my Go json.Unmarshal embedded struct field always zero?โ€‹

encoding/json resolves same-name JSON keys by depth: the shallower outer field wins and the embedded struct's same-name field is never populated. Unmarshal the same data twice โ€” once into Config, once into a pointer-only probe struct โ€” and copy explicit values over the defaults. Add a regression test asserting explicit values survive.

How do I update Fyne UI from a background goroutine in v2.6โ€“2.8?โ€‹

In Fyne 2.6โ€“2.8 the strict threading model and fyne.Do are opt-in: set fyneDo=true under [Migrations] in FyneApp.toml or build with -tags migrated_fynedo (default only from v2.9). Wrap every UI update in fyne.Do, otherwise behavior is not guaranteed.

Why does my Go SSH fingerprint check always fail?โ€‹

Padding: ssh-keygen prints 43 unpadded base64 characters for a 32-byte ed25519 key, while Go's base64.StdEncoding appends '='. Normalize both sides โ€” strip trailing '=', unify the 'SHA256:' prefix โ€” before comparing, or use x/crypto's ssh.FingerprintSHA256, which is already unpadded.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

Python json.dumps with default=str turns a set into a string? The hidden substring-match trap

ยท 7 min read

When you persist a dict containing a Python set with json.dumps(data, default=str) and later read it back to test membership with in, the result is silently wrong โ€” no exception, but the in checks are completely off.

Encountered this while building AI Ops โ€” AI-powered analytics that surfaces market trends, user behavior, and sales data to drive precise operational strategy. In a decision-replay feature for an analysis template, I needed to serialize a "missing months" set into a snapshot and read it back to decide whether a given month was missing. After replay, months that should have been flagged "missing" were silently judged "not missing" โ€” with zero exceptions anywhere in the chain.

TL;DRโ€‹

default=str is not a universal escape hatch. It hands set to str(), storing a literal string like "{1, 2}" in the JSON instead of an array; the type is irreversible on reload, and an in check against it degrades to substring matching, silently returning wrong results. When set is involved, the correct approach is to convert to list before serializing and rebuild with set() on read.

The symptomโ€‹

This code fully reproduces the silent failure:

import json

# A dict containing a set โ€” say, "months that still need backfill"
data = {"missing_months": {"3", "5", "12"}}

# Serialize with default=str (the common "just don't crash" shortcut)
serialized = json.dumps(data, default=str)
print(serialized)
# {"missing_months": "{'3', '5', '12'}"} โ† a string, not an array!

# Read it back
back = json.loads(serialized)
value = back["missing_months"]
print(type(value)) # <class 'str'> โ† no longer a set

# Silent bug: you meant to test membership in the "missing" set
print("1" in value) # True โ† 1 is NOT in {3,5,12}, but "1" is a substring of "12"!
print("3" in value) # True โ† correct by coincidence
print("9" in value) # False

"1" in value returns True, yet the original set {"3", "5", "12"} does not contain "1". No exception, no warning โ€” the result is just quietly wrong. This kind of bug is especially dangerous in branches that act on the check (e.g. "is this month missing data? if so, backfill it").

Root causeโ€‹

Three layers:

Layer 1: set is not JSON serializable to begin with. JSON has only array (list) and object โ€” no set type. A direct json.dumps({"x": {1, 2}}) raises TypeError: Object of type set is not JSON serializable.

Layer 2: default=str turns the error into silent corruption. The default parameter of json.dumps is called for objects that can't be serialized, and is expected to return a serializable value. When default=str, the object goes to str() โ€” so a set becomes its Python literal form {'3', '5', '12'}, stored as a string in the JSON:

>>> json.dumps({"m": {"3", "5", "12"}}, default=str)
'{"m": "{\'3\', \'5\', \'12\'}"}'

The error is gone โ€” at the cost of the type silently changing from set to str, with no signal that it happened.

Layer 3: in means different things for str vs set. This is the core of the silent bug. For set/list, x in s is a membership test; for str, x in s degrades to substring matching. The reloaded value is the string "{'3', '5', '12'}", so "1" in "{'3', '5', '12'}" tests whether the substring "1" appears โ€” and since "12" contains "1", it returns True.

This is the same family of trap as Airflow PostgresHook silently dropping multi-statement SQL results: the most dangerous bugs don't throw โ€” they silently return the wrong answer, leaving you no signal to investigate.

The fixโ€‹

Core principle: store only standard JSON types; rebuild set semantics on the read side.

The most direct and controllable approach โ€” when you know where the set is, convert it to list in place:

import json

# Before serializing: set โ†’ list (a standard JSON array)
data = {"missing_months": list({"3", "5", "12"})}
serialized = json.dumps(data)
print(serialized)
# {"missing_months": ["3", "5", "12"]} โ† a proper JSON array

# Rebuild the set after reading back
back = json.loads(serialized)
months = set(back["missing_months"])
print("1" in months) # False โœ“
print("3" in months) # True โœ“

The serialized result is a clean JSON array โ€” portable, readable, and restorable.

Option 2: a custom default function (when data is complex)โ€‹

If the data structure is deep and you're not sure where a set might sneak in, use a default function dedicated to collection types โ€” preserving semantics while still falling back for other non-standard types:

import json

def safe_default(obj):
# Collection types โ†’ list, kept as a standard JSON array
if isinstance(obj, (set, frozenset)):
return sorted(obj) # sort for stable, predictable output
# Only fall back to str for types that truly can't be represented
return str(obj)

data = {"missing_months": {"3", "5", "12"}, "created_at": some_datetime}
serialized = json.dumps(data, default=safe_default)
# {"missing_months": ["3", "5", "12"], "created_at": "..."}

back = json.loads(serialized)
months = set(back["missing_months"])
print("1" in months) # False โœ“

Compared to a blind default=str, this function handles "types you need to preserve" (collections) explicitly and only falls back to str for genuinely unrepresentable types โ€” minimizing silent risk.

Caveats

  • default=str is "silent", not "safe": it removes the error but flattens set/tuple/datetime/custom objects into strings irreversibly. Any operation that depends on the original type after reload (in membership, arithmetic, comparison) can misbehave.
  • tuple has the same problem: str((1, 2)) is "(1, 2)", and in against it also degrades to substring matching. Handle collection-like containers the same way: serialize as list.
  • Cross-process / cross-language portability is the litmus test: if this JSON will be read by Node.js, Go, etc., the "{1, 2}" produced by default=str is just a plain string there โ€” not even a valid Python literal โ€” and is nearly impossible to restore. Stick to standard JSON types for portability.
  • Convert at the source when possible: rather than patching with default after the fact, store collection semantics as list when you build the data structure, keeping set out of the serialization pipeline entirely.

FAQโ€‹

How do I convert a Python set to JSON?โ€‹

A set has no native JSON type, so json.dumps raises TypeError. Convert it with list(set) before serializing to store a standard JSON array, then rebuild with set() when reading it back. This avoids the error and fully restores the set semantics, across languages too.

How do I fix "Object of type set is not JSON serializable" in json.dumps?โ€‹

The root cause is that sets aren't JSON serializable. The safe fix is to convert the set to a list before dumping, or pass a default function that returns list(obj) for isinstance(obj, (set, frozenset)). Avoid default=str โ€” it doesn't crash, but it stores the set as a string, so the type can't be restored on read.

Why does in return wrong results after serializing a set with default=str?โ€‹

default=str passes the set to str(), storing the literal string '{1, 2}' in JSON. On reload the value is a str, not a set, so x in s degrades from membership testing to substring matching โ€” e.g. "1" in "{'3','5','12'}" returns True because "12" contains the character "1", even though the original set doesn't contain "1". The fix is to serialize as list and rebuild with set() on read.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

Airflow XCom Throws 'Out of range float values are not JSON compliant'? Blame pandas NaN

ยท 7 min read

When an Airflow task calls ti.xcom_push() to pass pandas-processed results downstream, the task crashes outright โ€” ValueError: Out of range float values are not JSON compliant: nan โ€” and the app's custom logs table shows no error at all.

Encountered this while building AI Ops โ€” an LLM-powered analytics pipeline where Airflow DAGs pull SQL data, process it with pandas, and pass results between tasks via XCom.

TL;DRโ€‹

XCom serializes with JSON under the hood, and Airflow calls json.dumps(..., allow_nan=False) to follow the JSON spec strictly โ€” which has no NaN / Infinity whatsoever. The moment a float NaN (converted from SQL NULL by pandas) enters the data passed to xcom_push, serialization throws ValueError. Fix: recursively walk the data before push and convert NaN / ยฑInf to None (JSON null).

Symptomsโ€‹

A quarterly report DAG failed, but the symptom was baffling โ€” in the app's custom logs table, steps 1โ€“4 for that trace were all fine, "analysis done" even logged twice (a retry), then it cut off: step 5 missing, with no error row at all:

trace=91c126c3
โ”œโ”€ step 1 SQL fetch โœ…
โ”œโ”€ step 2 pandas process โœ…
โ”œโ”€ step 3 rule judge โœ…
โ”œโ”€ step 4 LLM analysis done โœ… โ† retried after this
โ””โ”€ step 5 XCom push result โŒ โ† missing, no error record

The real traceback was only in the Airflow task log:

# inside container /opt/airflow/logs/dag_id=ai_analysis_v2/run_id=.../task_id=analyze_results/attempt=N.log
ValueError: Out of range float values are not JSON compliant: nan
File ".../ai_analysis_tasks.py", line 142, in analyze_results
ti.xcom_push(key='sql_metadata', value=result)

The crash landed exactly on ti.xcom_push โ€” the instant the task pushed results into XCom.

Root Causeโ€‹

Three layers stack up, all required:

1. The JSON spec has no NaN / Infinity. RFC 8259 only allows finite numeric literals. Python's json.dumps will happily emit bare NaN and Infinity by default, but those are Python-specific extensions, not valid JSON โ€” any strict parser (Airflow included) rejects them.

2. Airflow XCom serializes with allow_nan=False. XCom's default JSON serializer explicitly disables NaN tolerance, so encountering NaN throws ValueError: Out of range float values are not JSON compliant instead of silently emitting invalid JSON.

3. pandas reads SQL NULL as NaN. pandas.read_sql returns float('nan') for SQL NULL columns. Once such a column flows through computation and to_dict('records') into the result object, NaN hitches a ride into xcom_push:

import pandas as pd

# A SQL NULL cell โ†’ pandas reads it as NaN
df = pd.DataFrame({"ad_roi": [1.2, None, 0.8]})
records = df.to_dict("records")
# [{'ad_roi': 1.2}, {'ad_roi': nan}, {'ad_roi': 0.8}] โ† nan slipped in

# downstream task crashes on push
ti.xcom_push(key="result", value=records)
# ValueError: Out of range float values are not JSON compliant: nan

This stayed latent for a long time because the data usually had values in those columns; it only surfaced when a client had zero ad spend for an entire quarter and ad_roi came back NULL across the board โ€” the first time NaN entered the XCom path at scale.

Why no error in the logs table? Because the crash happens during XCom serialization, outside the task function's try/except โ€” the exception bubbles straight up to the Airflow scheduler and only lands in Airflow's own task log. The app's custom logs table catch never gets a chance to record it. That's what makes this failure so confusing: it looks "silent."

Solutionโ€‹

Scrub all NaN / ยฑInf from the data before it enters XCom.

1. Write a pure recursive cleanerโ€‹

import math

def json_safe_value(obj):
"""
Recursively convert NaN / +Inf / -Inf to None so the data is
strictly JSON-serializable. Handles dict / list / tuple / scalar;
unknown types pass through unchanged.
"""
if isinstance(obj, float):
if math.isnan(obj) or math.isinf(obj):
return None
return obj
if isinstance(obj, dict):
return {k: json_safe_value(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [json_safe_value(v) for v in obj]
return obj

Why not df.fillna(None)? Because fillna(None) on numeric columns is unstable across pandas versions and dtypes โ€” sometimes it coerces the dtype instead of nulling values. It also only handles DataFrames, not floats already nested inside dicts/lists after to_dict. Recursive cleaning at the "data is now native Python structures" layer is the most robust fallback.

2. Centralize the guard before pushโ€‹

The worry-free approach is to hang the cleanup on the single chokepoint all xcom_push calls go through, rather than remembering to call it at every push site:

def push_safe(ti, key, value):
"""Clean NaN/Inf before XCom push to prevent serialization crashes."""
ti.xcom_push(key=key, value=json_safe_value(value))

# inside the task
push_safe(ti, "sql_metadata", result)
push_safe(ti, "processor_output", processor_result)

3. Fix the "silent failure" observability gapโ€‹

Fixing serialization alone isn't enough โ€” the gap where exceptions outside try/except never reach the app's logs table must be closed too. Attach a failure decorator that logs the top-level exception to your table before re-raising:

import functools
import logging

logger = logging.getLogger(__name__)

def log_task_failure(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
try:
return fn(*args, **kwargs)
except Exception:
logger.error("task %s failed", fn.__name__, exc_info=True)
# write the traceback into the app's custom logs table here
raise
return wrapper

@log_task_failure
def analyze_results(**context):
...

Now if another exception slips outside a catch, the app's logs table still gets an error row โ€” no more "silent failure."

After the fix, rerunning the same conf: DAG all green, DB write success, and the formerly-NaN ad_roi lands as null in the database; downstream is happy.

On the same Airflow analytics pipeline, this isn't the only way data silently misbehaves โ€” PostgresHook silently dropping multi-statement SQL results is another classic.

Notesโ€‹

Notes

  • json.dumps defaults to allow_nan=True, which is a footgun: it silently emits bare NaN / Infinity as invalid JSON, and the crash only shows up when a strict parser downstream (Airflow XCom, JS JSON.parse) reads it. Always pass allow_nan=False explicitly when serializing data that crosses a process boundary, to surface the problem early.
  • ยฑInfinity bites too: float('inf') / float('-inf') are excluded from the JSON spec just like NaN; json_safe_value must handle them together.
  • XCom has more than one serializer: Airflow also supports binary object serialization, which can store arbitrary Python objects, but such XCom values are unreadable, not version-portable, and carry deserialization security risk. In production, stick with JSON and clean the data.
  • Triage heuristic: when a logs-table trace cuts off with no error row, go straight to the Airflow task log (inside the container at /opt/airflow/logs/dag_id=.../task_id=.../) for the traceback โ€” "no app log" does not mean "no error."

FAQโ€‹

How to fix Airflow "Out of range float values are not JSON compliant"?โ€‹

XCom serializes with json.dumps(allow_nan=False) and ran into NaN / Infinity, which the JSON spec does not allow. The usual root cause is pandas reading a SQL NULL into float('nan') that then flows into xcom_push. Fix it by recursively converting NaN / ยฑInf to None (JSON null) before push, centralized in a pure json_safe_value helper.

Why does my Airflow task fail but my custom logs table has no error?โ€‹

If the exception happens during XCom serialization, outside the task function's try/except, it only bubbles up to the Airflow scheduler and lands in the Airflow task log (inside the container at /opt/airflow/logs/). The app's custom logs table catch never sees it, so it looks like a "silent failure." To triage, read the Airflow task log traceback directly instead of only checking app logs.

Can Airflow XCom store pandas NaN directly?โ€‹

No. XCom defaults to JSON serialization, and the JSON spec only has finite numbers โ€” no NaN / Infinity. The right fix is to convert NaN to None (JSON null) before push. Switching to binary object serialization sidesteps the type limit but produces unreadable, non-portable values with deserialization security risk; not recommended for production.


CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me