Skip to content

Traefik dynamic config is written non-atomically, briefly dropping all app routes #228

Description

@ClaydeCode

What happens

write_traefik_dyn_config() rewrites the single dynamic-config file that carries the routers for every installed app, and the write is not atomic:

https://github.com/FreeshardBase/freeshard/blob/main/shard_core/service/app_installation/util.py#L157-L162

async def _write_to_yaml(spec: pydantic.BaseModel, output_path: Path):
    output_path.parent.mkdir(exist_ok=True, parents=True)
    async with aiofiles.open(output_path, "w") as f:
        await f.write("# == DO NOT MODIFY ==\n# this file is auto-generated\n\n")
        await f.write(yaml.dump(spec.model_dump(exclude_none=True)))

Mode "w" truncates the file immediately, and the content is then written in two separate awaited writes. Traefik's file provider watches this path and re-reads on change. A reload landing inside that window sees either an empty file or a config with the header and no http: section, i.e. no routers at all — and Traefik answers every request for an unknown router with its own 404.

The file is rewritten on every install and uninstall (worker.py:150) and at startup (app_factory.py:87), so any app-install activity can knock out routing for all apps on the shard for the duration of the window.

Why it matters

The user-visible symptom is a 404 on an app that was working a moment ago, typically right after the app-starting splash screen. That 404 also kills the splash's own JS reload, so the page does not recover on its own and the user sees a dead tab rather than an app that starts a few seconds later. It is intermittent by nature, which makes it hard to attribute.

It also confuses any automated check: a 404 on an app URL currently has two very different meanings — "this app has no route because it is queued or in ERROR" (NOT_ROUTABLE_STATUS, util.py:27) and "routing is briefly gone for everyone".

Suggested fix

Write to a temporary file in the same directory and os.replace() it into place. The rename is atomic on POSIX, so Traefik either reads the previous complete config or the next one, never a partial file:

async def _write_to_yaml(spec: pydantic.BaseModel, output_path: Path):
    output_path.parent.mkdir(exist_ok=True, parents=True)
    tmp_path = output_path.with_suffix(output_path.suffix + ".tmp")
    async with aiofiles.open(tmp_path, "w") as f:
        await f.write("# == DO NOT MODIFY ==\n# this file is auto-generated\n\n")
        await f.write(yaml.dump(spec.model_dump(exclude_none=True)))
    os.replace(tmp_path, output_path)

Same filesystem, same directory, so the rename cannot fail with EXDEV.

How it was found

Reading the routing path while designing an unattended smoke test for app-update bundles in the app-repository, after the intermittent 404 was reported from manual testing. Not reproduced deliberately — the race is timing-dependent — so treat the mechanism as strongly suspected rather than confirmed. The non-atomic write is not in doubt either way, and the fix is worth making on its own.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions