Have you ever rerun one start command remotely and watched it die with a message your laptop never printed? I spent two days in that loop, and the Python worker was never the process that actually failed. The wrapper script was three lines long, looked completely harmless, and failed before exec could even happen. This is the field notebook I wish I had opened before I blamed the interpreter again.
Hour 0: the job “started” and immediately vanished
I had a tiny Python worker and a shell wrapper that loaded a local env file, then replaced itself with the interpreter. On my laptop that wrapper felt boring, which is usually when I stop reading it. I copied the same tree onto a free remote box, ran the same entry command, and got a non-zero exit before any Python traceback appeared. Why would three lines fail when the worker file itself had not changed?
The first error was short enough to ignore, which is how I lost the morning. It said source: not found, or in one retry .: filename argument required, depending on which line I had “fixed” by muscle memory. I assumed a missing file, because that is the story my brain prefers. I then wasted a full pass confirming the env file existed, was readable, and had Unix line endings.
Here is the wrapper I actually shipped, with the comment I wrote to future me, who did not read it:
#!/bin/sh
set -e
source ./env.sh
exec python3 worker.py
Would you have stared at python3 first? I did, because that is the name I trust, and because recent bruises had trained me to doubt the interpreter path. The interpreter never got a chance to speak.
Hours 1–8: I debugged Python while the shell was already dead
I added prints to worker.py like a person who has not accepted the crime scene. I added logging.basicConfig and a Path(__file__).resolve() dump, then redeployed, then wondered why no log file appeared. If the process never starts, the log path you picked cannot save you. That sentence is obvious after the fact and invisible while you are still in Python.
I ran the worker directly and it started. That made the wrapper look innocent again, which is a nasty kind of green test. Direct execution used my interactive shell, and my interactive shell was bash. The remote start path was not bash. Do you see the trap forming, or do you still want to strace Python?
Commands I actually ran, in the order that slowly embarrassed me:
-
python3 worker.py— succeeded, so I blamed “the server.” -
./start.shfrom a login shell — succeeded on the laptop, failed on the box. -
head -n 1 start.sh— confirmed#!/bin/sh, which I had treated as a synonym for bash. -
ls -l /bin/sh— this is the line that should have happened at minute ten.
On Debian-family images, /bin/sh is often dash, not bash. Dash is a POSIX shell. source is a bashism. POSIX reads a file with . ./env.sh, and it needs the path, not a bare filename, when you want the current directory. I had been speaking a dialect and calling it portable.
Hours 8–24: the bashisms kept breeding
After I replaced source with ., the next failure was [[. Then echo -e. Then $RANDOM in a comment I had promoted into real code. Each fix revealed another courtesy my laptop had been extending for years. Have you noticed how generated wrappers accumulate bash like lint accumulates on a heater?
I eventually moved the same wrapper onto a free remote box so I could watch a non-interactive shell, and I used MonkeyCode's free model access while iterating on the script. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The free server option mattered because my laptop login shell kept hiding the bug; the free model access mattered only as a fast second reader for POSIX rewrites, not as an oracle.
The model, like me, preferred the dialect it sees most often in training snippets. It happily emitted source, [[ -n $FOO ]], and export FOO=bar && source env.sh in one breath. I had to treat those suggestions as untrusted patches and re-run them under /bin/sh -x. If you let a helper rewrite a wrapper, who is checking the shebang against the syntax?
A compact contract the wrapper must keep
I now keep this table next to any script that claims #!/bin/sh:
-
source file— bash; POSIX fix is. ./filewith an explicit path. -
[[ -n $x ]]— bash; POSIX fix is[ -n "$x" ]with quotes. -
echo -e 'a\n'— unportable; POSIX fix isprintf 'a\n'. -
$RANDOM— bash; POSIX fix is a tiny Python one-liner orawk. -
command <(other)— bash process substitution; POSIX fix is a temp file. -
set -o pipefail— bash; POSIXset -edoes not mean the same thing. -
function start {— bash-ish; POSIX fix isstart() {. - arrays and
${name[@]}— bash; POSIX has no arrays.
I do not memorize that list because I am virtuous. I memorize it because I already paid two days of rent on it.
The artifact: prove the shell before you prove the worker
Label this as a checklist I now run before I accuse Python. It is a reproducible gate, not a benchmark, and it does not require a special model name or a special machine story.
1. Print the real shell, not the one you wish you had
#!/bin/sh
set -e
echo "arg0=$0"
echo "SHELL=${SHELL-unset}"
ls -l /bin/sh || true
readlink /bin/sh || true
ps -p $$ -o pid,comm,args 2>/dev/null || true
command -v python3
python3 -c 'import sys,os; print("exe", sys.executable); print("cwd", os.getcwd()); print("sh_tty", sys.stdin.isatty(), sys.stdout.isatty())'
Run it three ways, and do not skip the boring one:
chmod +x probe_shell.sh
./probe_shell.sh
sh ./probe_shell.sh
bash ./probe_shell.sh
If those three disagree, your wrapper is not portable yet. Which of those three matches the free server’s start path? If you cannot answer, you are still debugging the laptop.
2. A Python worker that refuses to start quietly
worker.py should fail loudly if the env file never loaded. Silent defaults are how a wrapper bug becomes a “Python logic” bug.
#!/usr/bin/env python3
import os
import sys
REQUIRED = ("APP_MODE", "APP_DATA_DIR")
def main() -> int:
missing = [name for name in REQUIRED if not os.environ.get(name)]
if missing:
print("missing env:", ",".join(missing), file=sys.stderr)
print("cwd=", os.getcwd(), file=sys.stderr)
print("exe=", sys.executable, file=sys.stderr)
return 2
print("worker_ok mode=", os.environ["APP_MODE"])
return 0
if __name__ == "__main__":
raise SystemExit(main())
If this prints missing env on the server and not on your laptop, stop touching Python. Go back to the wrapper. Are you still tempted to add retries around a process that never inherited the variables?
3. POSIX env loader
#!/bin/sh
set -e
ENV_FILE=${1:-./env.sh}
[ -f "$ENV_FILE" ] || {
echo "missing $ENV_FILE" >&2
exit 1
}
# POSIX dot builtin; path is required for the current directory.
. "$ENV_FILE"
exec python3 worker.py
And env.sh itself should stay boring:
# env.sh — POSIX assignments only
APP_MODE=live
APP_DATA_DIR=/tmp/app-data
export APP_MODE APP_DATA_DIR
No source, no [[, no arrays, no echo -e. If you need random values, call Python. If you need bash, change the shebang to #!/usr/bin/env bash and then require bash on the server. Mixing those strategies is how I bought a 48-hour ticket.
Hours 24–48: what actually broke, in one sentence
The wrapper declared POSIX, used bash, and the free server believed the shebang. My laptop never ran that contract. The Python worker was a bystander with a good alibi. Once /bin/sh -x ./start.sh was in the notebook, the rest was just deleting dialect.
I would repeat these habits, in this order:
- Read the shebang out loud, then resolve
/bin/shon the same host that will run the job. - Run the wrapper under
sh -xand underbash -x, and keep both transcripts. - Make the worker reject missing environment instead of defaulting to laptop-shaped values.
- Treat generated shell as untrusted, especially around
source,[[, andecho -e. - Keep the env file as assignments plus
export, not as a second program.
Would I repeat asking a coding model to “just make the start script nicer”? Only with the probe above as the merge gate. A nicer script that only bash can parse is not nicer on a box whose /bin/sh is dash.
Limitations, and who should not copy this
This notebook is for small Python workers started by a POSIX wrapper on a Unix-like free server. It is not a production hardening guide, and it does not measure throughput, quota, or uptime. I am not claiming any model ranking, any hardware profile, or any permanent free-tier promise.
Do not use this approach if your team already standardizes on #!/usr/bin/env bash and installs bash everywhere on purpose. Do not use it on Windows-centric flows where sh means something else. Do not use a free server for secrets, regulated data, or anything that needs a documented SLA. Do not paste env files into a model prompt; the bug here was syntax, not a request to share values.
If your wrapper is actually a whole deploy system, this checklist is too small. You want an image with a pinned shell, a unit test that runs sh -n, and a start command that is identical in CI and on the box. I did not have that, which is why a three-line file survived review.
The next time a remote job dies before Python speaks, ask one rude question first: which shell ran line one? If you already keep a free remote box and a free model in the loop, run sh -x on the wrapper before you let either of them rewrite the worker again.
Top comments (0)