Skip to content

A ref from a replaced computer still resolves, because the run it is checked against is held in one process's memory #236

Description

@zopeVaibhav

A ref points at an element on a page. It is only meaningful for the run of the computer that produced it, so the gateway checks the run before resolving one. That check does not run on a replica that did not take the snapshot, and on a single process it runs one action too late. Either way a ref from a computer that has since been replaced still resolves, and the click goes through.

What it looks like

Three clicks, all carrying a ref from a computer that no longer exists:

cold replica click:             {"action":"click","element":{"role":"button","name":"Submit order"}}
first click after replacement:  {"action":"click","element":{"role":"button","name":"Submit order"}}
second click after replacement: {"name":"StaleSnapshotError"}

The third one is what should happen every time. The first two are allowed, and the audit row for each records Submit order — a button on a page that is gone.

Why it happens

The server remembers which run of a computer it last saw in a plain Map inside one process (supervisor.ts:25). locate writes to that map (:172) and sessionOf reads from it (:164) without asking the supervisor anything. So:

  • Another replica has nothing in its map for this Bot, and sessionOf returns undefined. An unknown run is not treated as a mismatch (gateway.ts:386), so the check is skipped.
  • On the process that does have a value, the value is old. govern reads it (gateway.ts:421) before this action's own locate has run, so it sees the run from the previous action. The next action sees the right one, which is why the second click is refused.

This is structural. The snapshot was moved into Postgres so any replica could resolve a ref against it. The run, which is what makes the snapshot's generation mean anything, stayed in one process's memory.

Reproduction

Against d293f23, as a Bun test using the real createDockerSupervisorProvider with a stubbed supervisor and computer.

  1. Build a gateway on the real provider, with /ensure reporting startedAt: "…T10:00:00.000Z".
  2. await gateway.snapshot("bot-a").
  3. Change the reported startedAt to "…T11:30:00.000Z". This is the supervisor replacing the container because the image tag moved, which it already does without telling the server.
  4. await gateway.click("bot-a", actor, { ref: "e9", snapshotId: 7 }). It should raise StaleSnapshotError. It is allowed instead, and audited as the old run's Submit order. Repeat the same click and it is refused.
  5. On a process that has never called locate for bot-c, save a snapshot for bot-c carrying the older run into the store, then click e9 at generation 7. sessionOf returns undefined, the check is skipped, and the click is allowed.

Step 5 is what every replica that did not take the snapshot does, until it has located that Bot itself.

Why it matters

A generation only counts within one run. A replaced container starts again at one and reaches generation 7 again, so the same ref resolves on both sides — to two different pages. A rule like "never click Confirm transfer" can then miss a click that is on it, and the audit row names an element the Bot never touched.

One decision this needs

What should an unknown run mean? Right now it means "carry on as before", which is what lets a replica skip the check. Treating it as a mismatch would refuse instead — safer, but it would also refuse for the single shared computer, which has no run to report at all. Worth deciding deliberately rather than leaving it to fall out of a truthiness test.

Severity

Moderate, and it fails open rather than closed. Nothing is wrong on a single process that never replaces a computer mid-conversation. It bites in the two cases this project treats as normal: more than one server replica, and an upgrade that moves the computer image while a conversation is open.

I have a reproduction as a Bun test and am happy to open a PR once there is a view on the question above.

Metadata

Metadata

Assignees

No one assigned

    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