DEV Community

Taylor Wang
Taylor Wang

Posted on

I Thought the Free Server Hung. It Was Waiting for a Line I Would Never Type.

Have you ever stared at a remote job that claimed it was still running, while your laptop version finished in seconds? I did exactly that, and I blamed the queue, the container image, and then a flaky network path. None of those were guilty, because the process was blocked on input() for a human who would never arrive. My laptop hid the bug for a day, since I still had a keyboard and a habit of pressing Enter.

This is a forty-eight hour field note about laptop-shaped code that looked finished on a notebook and froze on a runner. A free model wrote a friendly setup helper, and a free server then ran that helper without any TTY attached. I learned, slowly, that headless machines do not answer prompts and do not share your notebook working directory. They also do not listen on 127.0.0.1 the way a leftover process on your laptop still might.

This is a reconstructed note about a failure class I keep repeating, not a claim about a named outage or a benchmarked fleet. The probe below is labeled so you can run it; I am not inventing pass rates, hardware, or quotas for anyone else.

What I thought was happening

The script was a small Python runner that prepared a fixture file, then started a tiny HTTP check. On my laptop it printed three lines and exited zero, which felt like proof instead of luck. On the free server it sat in "running" until I cancelled it, which felt like infrastructure instead of a blocked read. I asked the usual questions, because older incidents had trained me to start there. Was the interpreter missing this time, or had I pointed the command at the wrong file again?

Those questions were leftover from older incidents, and they quietly wasted the entire first evening of debugging. The process list, once I finally captured it, showed a living Python interpreter parked on a stdin read, computing nothing. Have you checked stdin, unbuffered logs, and the real cwd before you blamed the scheduler itself? I had not, and that omission cost me a night of looking at the wrong layer.

Hours 0–8: I chased the wrong layer

Silent logs are not evidence of a stuck queue

I started where most of us start, which is logs that were never going to exist in the first place. The script only printed after the prompt returned, so the remote job looked completely silent. I added more print calls, then forgot that stdout may be block-buffered when there is no TTY. Have you ever "fixed" logging by adding prints that only flush when the process finally dies?

Here is the first command I wish I had run before touching any runner UI:

python3 -c "import sys; print('isatty', sys.stdin.isatty(), 'encoding', sys.stdin.encoding)"
PYTHONUNBUFFERED=1 python3 -u your_script.py
Enter fullscreen mode Exit fullscreen mode

The -u flag unbuffers stdout and stderr, which makes a hung job distinguishable from a merely quiet job. I did not use it until hour six, which is later than I want to admit in writing. I also tailed the wrong log file because I assumed the working directory on the server matched my repo root. What broke in this chapter was my mental model of "running," because a process can be alive and still be waiting on you.

A free server will not type yes just because a generated helper asked it to be friendly. That sentence would have saved me if I had believed it before midnight. Instead I restarted the job twice, as if a fresh container would grow a keyboard.

Hours 8–24: the model was being helpful in the worst way

Friendly on a laptop is often fatal on a runner

I had asked a free model, through MonkeyCode, to add a "safe setup" step before the HTTP check. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option were useful here in a boring, specific way: I could draft a helper and then run the same file in a room without a keyboard. I did not need a model name, a quota story, or a permanence claim. I needed two rooms that disagreed, and I needed them on the same script.

The helper the model offered looked innocent, which is why I almost shipped it unchanged:

# proposed example — do not treat this as production code
from pathlib import Path
import json
import urllib.request

root = Path("fixtures")
root.mkdir(exist_ok=True)

name = input("fixture name: ").strip() or "demo"
path = root / f"{name}.json"
path.write_text(json.dumps({"ok": True}))

with urllib.request.urlopen("http://127.0.0.1:8000/health") as resp:
    print(resp.status, path)
Enter fullscreen mode Exit fullscreen mode

Do you see the three laptop assumptions stacked on top of each other like unpaid technical debt? Relative fixtures, an input() call, and a health check pinned to 127.0.0.1 all passed on my machine for different accidental reasons. The folder existed because I had created it last week during an unrelated experiment. The prompt returned because I was sitting there, and the health check worked because a leftover local server was still bound.

On the free server, input() never returned, so I never reached the HTTP line at all. I spent hours "fixing" a bind address that the process had not tried yet, which is the mean part of a hang. It hides the next bug behind a spinner that still looks healthy. Would you rather have a red exit in ten seconds, or a green spinner for two days?

Hours 24–48: I wrote a probe instead of another prompt

Fail loud before the script asks a person who is not there

I stopped asking the model for a cleverer script, because clever was how I got the prompt in the first place. I wrote a probe that fails loudly when a script is about to assume a laptop, and I ran it in both rooms. You can run it locally and on any headless runner; treat the values it prints as facts about that process, not as a product claim. This is the artifact I actually needed, and it is unlabeled magic-free on purpose.

#!/usr/bin/env python3
"""laptop_assumptions.py — proposed probe, run it yourself."""
from __future__ import annotations

import os
import socket
import sys
from pathlib import Path


def check_tty() -> dict:
    return {
        "stdin_isatty": sys.stdin.isatty(),
        "stdout_isatty": sys.stdout.isatty(),
        "prompt_would_block": (not sys.stdin.isatty()),
    }


def check_cwd() -> dict:
    here = Path(__file__).resolve().parent
    cwd = Path.cwd()
    return {
        "cwd": str(cwd),
        "script_dir": str(here),
        "cwd_equals_script_dir": cwd == here,
        "fixtures_from_cwd": str((cwd / "fixtures").resolve()),
        "fixtures_from_script": str((here / "fixtures").resolve()),
    }


def check_loopback(port: int = 8000) -> dict:
    sock = socket.socket()
    sock.settimeout(0.3)
    reachable = False
    err = None
    try:
        sock.connect(("127.0.0.1", port))
        reachable = True
    except OSError as exc:
        err = type(exc).__name__
    finally:
        sock.close()
    return {
        "loopback_port": port,
        "loopback_open": reachable,
        "loopback_error": err,
        "bind_all_interfaces_hint": "0.0.0.0 or ::",
    }


def check_fs() -> dict:
    home = Path.home()
    tmp = Path(os.environ.get("TMPDIR") or "/tmp")
    return {
        "home": str(home),
        "home_writable": os.access(home, os.W_OK),
        "tmp": str(tmp),
        "tmp_writable": os.access(tmp, os.W_OK),
        "user": os.environ.get("USER") or os.environ.get("USERNAME"),
        "tz": os.environ.get("TZ") or "unset",
    }


def main() -> int:
    report = {
        "tty": check_tty(),
        "paths": check_cwd(),
        "loopback": check_loopback(),
        "fs": check_fs(),
        "python": sys.version.split()[0],
        "executable": sys.executable,
        "argv": sys.argv,
    }
    for section, payload in report.items():
        print(f"[{section}]")
        if isinstance(payload, dict):
            for key, value in payload.items():
                print(f"  {key}={value!r}")
        else:
            print(f"  {payload!r}")
    if report["tty"]["prompt_would_block"]:
        print("FAIL: stdin is not a TTY; input() will hang or raise EOFError")
        return 2
    print("PASS: stdin looks interactive; still do not ship input() to a server")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

Run it twice, once in each room, and keep the -u flag so the report cannot hide behind a buffer:

python3 -u laptop_assumptions.py
# then on the free server, same file, same flags
python3 -u laptop_assumptions.py; echo exit:$?
Enter fullscreen mode Exit fullscreen mode

I also wrapped the original helper so a missing TTY becomes an error instead of a silent wait. This is the change I would repeat without thinking, because a red exit is cheaper than a polite hang:

# proposed guard — label this as an example until you run it
import argparse
import sys
from pathlib import Path


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="noninteractive fixture writer")
    parser.add_argument("--fixture-name", default="demo")
    parser.add_argument(
        "--base-dir",
        default=str(Path(__file__).resolve().parent / "fixtures"),
    )
    return parser.parse_args()


def refuse_prompts() -> None:
    if sys.stdin.isatty():
        raise SystemExit("refusing to run an interactive prompt in a runner-shaped job")
    # closed or empty stdin should fail fast, not hang on input()
    if sys.stdin.closed:
        raise SystemExit("stdin is closed; pass --fixture-name instead of input()")


def main() -> int:
    refuse_prompts()
    args = parse_args()
    base = Path(args.base_dir)
    base.mkdir(parents=True, exist_ok=True)
    path = base / f"{args.fixture_name}.json"
    path.write_text('{"ok": true}\n', encoding="utf-8")
    print(f"wrote {path.resolve()}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

Notice I inverted my first instinct, because I do not want a server job that helpfully prompts a person. I want a server job that dies if anybody tries to prompt, including me. Flags, environment variables, and a committed fixture file are boring, and boring is the point after a two-day hang.

A small decision table I actually needed

I keep this next to the probe because it stops me arguing with the wrong layer at hour three. It is not science, and it is not a vendor comparison. It is a checklist for the next time a job looks frozen and my hands reach for the restart button.

Symptom on the free server Laptop reason it was hidden First check Do not do this
Job stays "running", no logs You answered input() without noticing stdin.isatty() and python3 -u Restart the runner hoping it unsticks
FileNotFoundError for fixtures/... Your IDE set cwd to the repo root Path(__file__).resolve().parent os.chdir to wherever the model suggested
Connection refused to health You still had a local server on loopback Probe 127.0.0.1 before the HTTP client Open the port to the world just to test
Prints appear only at process exit stdout is block-buffered without a TTY -u or PYTHONUNBUFFERED=1 Add more print and wait longer
Exit 0, empty artifact Relative path wrote into a different directory Print resolved paths before every write Trust ls in a directory you did not print

Set the environment explicitly when you compare rooms, or you will debug timezone folklore next:

export PYTHONUNBUFFERED=1
export TZ=UTC
python3 -u laptop_assumptions.py
python3 -u your_script.py --fixture-name demo --base-dir "$PWD/fixtures"
Enter fullscreen mode Exit fullscreen mode

Pass the fixture name as a flag, not as a prompt, because that single change would have saved the first evening. Relative paths can stay, but only after you pin them to __file__ instead of to whatever cwd the runner inherited.

What broke, in plain language

Three things broke, and none of them were "the free server is flaky" as a personality trait.

  1. The prompt broke first. input() is not a setup API, because it is a conversation with a person who may not exist on the other side of the job.
  2. The working directory broke second. Relative paths are coordinates in a process, not properties of a file that travels with your git checkout.
  3. The loopback address broke last, and I never reached it until the hang was gone. 127.0.0.1 is the process's own loopback, so another machine cannot see the server you left running at home.

The free model did what I asked, and I asked for friendly, which was the real defect in the request. Friendly on a laptop is often fatal on a runner, and that is a context mismatch rather than drift. I refused to name that mismatch until hour thirty, which is why this note exists.

What I would repeat

I would run the probe before I run the script, every time the script has left my laptop and entered a headless room. I would force unbuffered output on the first remote attempt, not the fifth, because silent hangs are too good at impersonating infrastructure. I would replace prompts with flags, environment variables, or a committed fixture file, even when the model offers a nicer question. I would keep HTTP checks inside the same process, or skip them, instead of assuming a leftover local server still exists.

I would still use a free model to draft the boring parts of a helper, because drafting is cheap when you already know the I/O shape. I would not let it choose stdin, cwd, or bind addresses without a probe sitting next to the patch. Generating code is cheap, and generating a hang is also cheap, which is the part I now treat as the real artifact.

Limitations, and who should not copy this

This workflow is a preflight, not a platform, and it will not grow into one if you run it twice. The probe does not measure model quality, server capacity, or network policy, and it does not know your production ingress. It will not stop you from running untrusted generated code as root, and it should not be used that way on a shared machine. It also will not replace a lockfile, a real staging clone, or a secret manager you already needed.

Skip this approach if your job is genuinely interactive, if you need an SLA, or if your runtime must match production hardware you have not described. A free server is a second room for catching laptop assumptions, and it is not a staging clone of production. If your check must bind on loopback only, do not "fix" it by listening on every interface for convenience. I am also not claiming a duration, a quota, or a model name for anyone else's account, because availability can change under you.

Re-run the probe on the machine you have today, not on the machine you remember from a blog post. If you already have a free model tab and a headless runner, run the probe on the next helper before you let it ask you a question. That is the whole lesson I needed, and I needed it later than I want to admit.

Top comments (0)