Skip to content

Commit 0335742

Browse files
committed
Dev agent: release builds open no port; 32-byte token minimum; port cannot be taken over (#233)
- The runtime starts the agent only when the variable is set and an installed payload root holds python_multiplatform_devagent.enable. The library is one artifact for debug and release, so the app's build decides: toolchain stages the marker into debug variants only. Without it the variable is ignored with a warning. - The token must be at least 43 characters (secrets.token_urlsafe(32)), or the agent refuses to start. - Windows sets SO_EXCLUSIVEADDRUSE. POSIX keeps SO_REUSEADDR only to rebind the launcher's port past TIME_WAIT; it does not let another socket bind the port while the agent listens (tested). - stop() deadlocked on the reader lock while a connection was blocked in a read; it now shuts the socket down and the serving thread closes itself.
1 parent d2418f0 commit 0335742

5 files changed

Lines changed: 176 additions & 36 deletions

File tree

‎docs/SPEC.md‎

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -136,18 +136,26 @@ fail, then implement.
136136
(resolution order; the simulator test task's `PYTHONHOME` still wins). The installed-app run on the
137137
simulator is a manual check, `docs/platforms/ios-app-bundle.md`.
138138
- **L-12** The dev agent (#233; toolchain `docs/design/live-connection.md`, option B): when the launcher sets
139-
`PYTHON_MULTIPLATFORM_DEV_AGENT` to `port=<n>;token=<t>[;overlay=<dir>]` (a debug launch; a release build
140-
never does), `Python3.initialize()` loads the embedded agent (`src/commonMain/python/
141-
python_multiplatform_devagent.py`, generated into Kotlin by `generateDevAgentSource`) as the module
142-
`python_multiplatform_devagent` and it listens on 127.0.0.1 only. A connection's first message must be
143-
`hello` with the token. Then, one JSON object per line: `execute` runs code in `__main__`'s persistent
144-
namespace, streams stdout and stderr, returns the last expression's value and reports errors with their
145-
traceback; `complete` completes from that namespace; `push` writes `.py` files into a writable overlay first
146-
on `sys.path` and re-imports the loaded ones, children first, through `typedpython.importgate` when the app
147-
has it (N-7b), keeping the old module when a re-import is refused or fails. A malformed request is printed
139+
`PYTHON_MULTIPLATFORM_DEV_AGENT` to `port=<n>;token=<t>[;overlay=<dir>]` **and** an installed payload root
140+
holds the marker `python_multiplatform_devagent.enable`, `Python3.initialize()` loads the embedded agent
141+
(`src/commonMain/python/python_multiplatform_devagent.py`, generated into Kotlin by `generateDevAgentSource`)
142+
as the module `python_multiplatform_devagent` and it listens on 127.0.0.1 only. The marker is the build-time
143+
switch: this library is one artifact for debug and release, so the app's build decides, and toolchain stages
144+
the marker into debug variants only. Without it the variable is ignored with a warning and no port opens. The
145+
token must be at least 43 characters (`secrets.token_urlsafe(32)`), or the agent refuses to start. The listening
146+
socket cannot be bound by another socket while it listens: `SO_EXCLUSIVEADDRUSE` on Windows; on POSIX,
147+
`SO_REUSEADDR` only so that a restarted app can rebind the launcher's port past TIME_WAIT. A connection's first
148+
message must be `hello` with the token. Then, one JSON object per line: `execute` runs code in `__main__`'s
149+
persistent namespace, streams stdout and stderr, returns the last expression's value and reports errors with
150+
their traceback; `complete` completes from that namespace; `push` writes `.py` files into a writable overlay
151+
first on `sys.path` and re-imports the loaded ones, children first, through `typedpython.importgate` when the
152+
app has it (N-7b), keeping the old module when a re-import is refused or fails. A malformed request is printed
148153
and does not stop the app. `Status: partial`: desktop
149-
(`PM/desktopTest/.../env/PythonDevAgentTest.kt`: the embedded agent answers over loopback, and a child JVM started with the variable has an agent on that port while one started without it has none; the protocol, `python-multiplatform/src/test/python/
150-
test_devagent.py`, CI `Dev agent protocol tests`); nothing sets the variable on Android or iOS yet
154+
(`PM/desktopTest/.../env/PythonDevAgentTest.kt`: the embedded agent answers over loopback; a child JVM with the
155+
marker and the variable has an agent on that port, one without the variable has none, one without the marker
156+
(a release build) opens no port with the variable set, and a short token opens no port; the protocol, token
157+
length, port takeover and restart: `python-multiplatform/src/test/python/test_devagent.py`, CI `Dev agent
158+
protocol tests`, macOS only so far); nothing sets the variable or stages the marker on Android or iOS yet
151159
(toolchain's M2, M3); no interrupt; `execute` runs on the agent's thread (no main-thread dispatch yet).
152160

153161
## 2. Low-level C API (downcall surface)

‎python-multiplatform/src/commonMain/kotlin/python/multiplatform/env/PythonDevAgent.kt‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,22 @@ import python.multiplatform.ffi.pyErrorOrGeneric
1111
* interpreter, designed in toolchain's `docs/design/live-connection.md` (option B).
1212
*
1313
* [Python3.initialize] calls [startIfRequested]. It does nothing unless the launcher set
14-
* [ENVIRONMENT_VARIABLE] (`port=<n>;token=<t>[;overlay=<dir>]`), which a debug launch does and a release
15-
* build never does; then the agent's Python ([DEV_AGENT_SOURCE], embedded from
16-
* `src/commonMain/python/python_multiplatform_devagent.py`) is loaded as the module [MODULE] and listens on
17-
* 127.0.0.1 with that token. A malformed request is reported on stderr and does not stop the app.
14+
* [ENVIRONMENT_VARIABLE] (`port=<n>;token=<t>[;overlay=<dir>]`) **and** the app was built for it: one of
15+
* its installed payload roots ([PythonPayload.installed]) holds [ENABLE_MARKER]. Then the agent's Python
16+
* ([DEV_AGENT_SOURCE], embedded from `src/commonMain/python/python_multiplatform_devagent.py`) is loaded as
17+
* the module [MODULE] and listens on 127.0.0.1 with that token, which must be at least 32 random bytes
18+
* (`secrets.token_urlsafe(32)`). A refused or malformed request is reported on stderr and does not stop the app.
19+
*
20+
* The marker is the build-time switch. This library is one artifact for an app's debug and release builds
21+
* alike, so a constant in it cannot tell them apart; the app's build can, and toolchain stages the marker
22+
* into a debug variant's payload only. Without it, a release build opens no port even if something starts
23+
* its process with the variable set: "a release launch never sets it" is the launcher's promise, which this
24+
* runtime does not rely on.
1825
*/
1926
internal object PythonDevAgent {
2027
const val ENVIRONMENT_VARIABLE = "PYTHON_MULTIPLATFORM_DEV_AGENT"
2128
const val MODULE = "python_multiplatform_devagent"
29+
const val ENABLE_MARKER = "python_multiplatform_devagent.enable"
2230

2331
private const val PY_FILE_INPUT: Int = 257
2432
private var loaded = false
@@ -27,6 +35,13 @@ internal object PythonDevAgent {
2735
fun startIfRequested() {
2836
if (requested || readEnvVar(ENVIRONMENT_VARIABLE).isNullOrBlank()) return
2937
requested = true
38+
if (enabledRoot() == null) {
39+
println(
40+
"WARNING: python-multiplatform dev agent not started: $ENVIRONMENT_VARIABLE is set, but no " +
41+
"payload root holds $ENABLE_MARKER, so this is not a build made for it (a release build).",
42+
)
43+
return
44+
}
3045
try {
3146
load()
3247
Python3.exec("__import__(\"$MODULE\").start_from_env()")
@@ -35,6 +50,10 @@ internal object PythonDevAgent {
3550
}
3651
}
3752

53+
/** The installed payload root that holds [ENABLE_MARKER], if any. */
54+
private fun enabledRoot(): String? =
55+
PythonPayload.installed.firstOrNull { pathIsAccessible("$it/$ENABLE_MARKER") }
56+
3857
/** Loads [DEV_AGENT_SOURCE] as the module [MODULE] (once). */
3958
fun load() {
4059
if (loaded) return

‎python-multiplatform/src/commonMain/python/python_multiplatform_devagent.py‎

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
55
The runtime starts it from `Python3.initialize()` only when the launcher sets the environment variable
66
`PYTHON_MULTIPLATFORM_DEV_AGENT` to `port=<n>;token=<t>[;overlay=<dir>]` (a debug launch does; a release
7-
build never does). It listens on 127.0.0.1 only. A connection's first message must be `hello` with the
8-
token; anything else closes the connection.
7+
build never does), and only in a build that staged the enabling marker (the runtime checks that; see
8+
`PythonDevAgent.kt`). It listens on 127.0.0.1 only. The token must be at least `MIN_TOKEN_LENGTH` characters
9+
(`secrets.token_urlsafe(32)`), or the agent refuses to start. A connection's first message must be `hello`
10+
with the token; anything else closes the connection.
911
1012
Messages are JSON objects, one per line, each carrying the request's `id`:
1113
@@ -39,6 +41,9 @@
3941
PROTOCOL = 1
4042
ENVIRONMENT_VARIABLE = "PYTHON_MULTIPLATFORM_DEV_AGENT"
4143
LOOPBACK = "127.0.0.1"
44+
# len(secrets.token_urlsafe(32)): 32 random bytes. The token is all that stands between any local process
45+
# and code execution in the app, so a short one is refused rather than used.
46+
MIN_TOKEN_LENGTH = 43
4247

4348

4449
def parse_request(value):
@@ -53,8 +58,9 @@ def parse_request(value):
5358
token = fields["token"]
5459
except (KeyError, ValueError):
5560
raise ValueError(f"{ENVIRONMENT_VARIABLE} must be port=<n>;token=<t>[;overlay=<dir>], not {value!r}")
56-
if not token or not 0 <= port <= 65535:
61+
if not 0 <= port <= 65535:
5762
raise ValueError(f"{ENVIRONMENT_VARIABLE} must be port=<n>;token=<t>[;overlay=<dir>], not {value!r}")
63+
_check_token(token)
5864
return {"port": port, "token": token, "overlay": fields.get("overlay") or None}
5965

6066

@@ -69,8 +75,17 @@ def start_from_env():
6975
return agent
7076

7177

78+
def _check_token(token):
79+
if not isinstance(token, str) or len(token) < MIN_TOKEN_LENGTH:
80+
raise ValueError(
81+
f"the dev agent's token must be at least {MIN_TOKEN_LENGTH} characters "
82+
"(secrets.token_urlsafe(32)); refusing to start"
83+
)
84+
85+
7286
def start(port, token, overlay=None):
7387
"""Start an agent on loopback; `port=0` picks a free one (the agent's `port` says which)."""
88+
_check_token(token)
7489
agent = Agent(token, overlay or tempfile.mkdtemp(prefix="python-multiplatform-overlay-"))
7590
agent.listen(port)
7691
return agent
@@ -123,7 +138,16 @@ def _install_gate(self):
123138

124139
def listen(self, port):
125140
self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
126-
self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
141+
if hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
142+
# Windows: SO_REUSEADDR there lets another socket bind the port while this one listens, and
143+
# without SO_EXCLUSIVEADDRUSE a same-user process can do so even when this one did not set it.
144+
self.server.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
145+
else:
146+
# POSIX: only so that a restarted app can bind the launcher's port while the last run's
147+
# connections sit in TIME_WAIT. It does not let another socket bind 127.0.0.1:<port> while this
148+
# one listens (Linux refuses a second listener; BSD and macOS need SO_REUSEPORT on both).
149+
# test_another_socket_cannot_take_the_port_over checks that on each platform the tests run on.
150+
self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
127151
self.server.bind((LOOPBACK, port))
128152
self.server.listen()
129153
self.port = self.server.getsockname()[1]
@@ -135,7 +159,7 @@ def stop(self):
135159
except OSError:
136160
pass
137161
for connection in list(self.connections):
138-
connection.close()
162+
connection.shutdown()
139163

140164
def _accept(self):
141165
while True:
@@ -161,6 +185,17 @@ def send(self, message):
161185
with self.send_lock:
162186
self.sock.sendall(data)
163187

188+
def shutdown(self):
189+
"""Wake the serving thread, which then closes the connection itself.
190+
191+
Closing the reader from another thread would wait on the buffered reader's lock, which the serving
192+
thread holds while it blocks in a read: stop() hung there.
193+
"""
194+
try:
195+
self.sock.shutdown(socket.SHUT_RDWR)
196+
except OSError:
197+
pass
198+
164199
def close(self):
165200
for closable in (self.reader, self.sock):
166201
try:

‎python-multiplatform/src/desktopTest/kotlin/python/multiplatform/env/PythonDevAgentTest.kt‎

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,17 @@ import kotlin.test.assertTrue
2121
* over a real loopback socket. The protocol itself is `src/test/python/test_devagent.py`'s.
2222
*/
2323
class PythonDevAgentTest {
24+
/** As long as the agent's minimum (`secrets.token_urlsafe(32)`). */
25+
private val token = "a".repeat(22) + "b".repeat(21)
26+
2427
@Test
2528
fun `the embedded agent runs in this interpreter and answers over loopback`() =
2629
PythonTestFixture.withInterpreter {
2730
PythonDevAgent.load()
2831
val overlay = Files.createTempDirectory("pmp-devagent").toFile()
2932
Python3.exec(
3033
"__import__('sys').modules['__main__'].__dict__['_pmp_devagent_test'] = " +
31-
"__import__('${PythonDevAgent.MODULE}').start(0, 'kt-token', '${overlay.absolutePath}')",
34+
"__import__('${PythonDevAgent.MODULE}').start(0, '$token', '${overlay.absolutePath}')",
3235
)
3336
val port = PythonTestFixture.eval("_pmp_devagent_test.port").toString().toInt()
3437
try {
@@ -38,7 +41,7 @@ class PythonDevAgentTest {
3841
val out = socket.getOutputStream()
3942
fun send(json: String) = out.write("$json\n".toByteArray(Charsets.UTF_8)).also { out.flush() }
4043

41-
send("""{"id": 1, "op": "hello", "token": "kt-token"}""")
44+
send("""{"id": 1, "op": "hello", "token": "$token"}""")
4245
val hello = reader.readLine()
4346
assertTrue("\"type\": \"hello\"" in hello && "\"protocol\": 1" in hello, hello)
4447

@@ -56,20 +59,21 @@ class PythonDevAgentTest {
5659
}
5760

5861
/**
59-
* The hook in `Python3.initialize()`: a process started with [PythonDevAgent.ENVIRONMENT_VARIABLE] has an
60-
* agent on that port with that token; one started without it has nothing listening. Each runs in its own
61-
* JVM ([DevAgentProbe]), since an environment variable cannot be set in this one.
62+
* The hook in `Python3.initialize()`: a debug build (its payload carries [PythonDevAgent.ENABLE_MARKER])
63+
* started with [PythonDevAgent.ENVIRONMENT_VARIABLE] has an agent on that port with that token; one started
64+
* without it has nothing listening. Each runs in its own JVM ([DevAgentProbe]), since an environment
65+
* variable cannot be set in this one.
6266
*/
6367
@Test
6468
fun `a process the launcher asked for an agent answers on the given port, and one it did not, does not`() {
65-
val requested = launchProbe(request = true)
69+
val requested = launchProbe(request = true, debugBuild = true)
6670
try {
6771
requested.awaitLine("python-multiplatform dev agent: listening on 127.0.0.1:${requested.port}")
6872
Socket("127.0.0.1", requested.port).use { socket ->
6973
socket.soTimeout = 10_000
7074
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
7175
val out = socket.getOutputStream()
72-
out.write("{\"id\": 1, \"op\": \"hello\", \"token\": \"launch-token\"}\n".toByteArray())
76+
out.write("{\"id\": 1, \"op\": \"hello\", \"token\": \"$token\"}\n".toByteArray())
7377
out.write("{\"id\": 2, \"op\": \"execute\", \"code\": \"6 * 7\"}\n".toByteArray())
7478
out.flush()
7579
assertTrue("\"type\": \"hello\"" in reader.readLine())
@@ -79,7 +83,7 @@ class PythonDevAgentTest {
7983
requested.process.destroyForcibly()
8084
}
8185

82-
val notRequested = launchProbe(request = false)
86+
val notRequested = launchProbe(request = false, debugBuild = true)
8387
try {
8488
notRequested.awaitLine(DevAgentProbe.READY)
8589
assertFailsWith<ConnectException> { Socket("127.0.0.1", notRequested.port).close() }
@@ -88,6 +92,37 @@ class PythonDevAgentTest {
8892
}
8993
}
9094

95+
/**
96+
* A release build (its payload has no [PythonDevAgent.ENABLE_MARKER]) opens no port even when its process
97+
* is started with the variable: "a release launch never sets it" is the launcher's promise, not this
98+
* runtime's guarantee, so the runtime does not rely on it.
99+
*/
100+
@Test
101+
fun `a release build opens no port even when the variable is set`() {
102+
val release = launchProbe(request = true, debugBuild = false)
103+
try {
104+
release.awaitLine(DevAgentProbe.READY)
105+
assertFailsWith<ConnectException> { Socket("127.0.0.1", release.port).close() }
106+
assertTrue(release.log.readLines().none { "listening" in it }, release.log.readText())
107+
assertTrue(release.log.readText().contains(PythonDevAgent.ENABLE_MARKER), release.log.readText())
108+
} finally {
109+
release.process.destroyForcibly()
110+
}
111+
}
112+
113+
/** A token shorter than the agent's minimum is refused before anything listens. */
114+
@Test
115+
fun `a debug build refuses a short token and opens no port`() {
116+
val short = launchProbe(request = true, debugBuild = true, token = "short-token")
117+
try {
118+
short.awaitLine(DevAgentProbe.READY)
119+
assertFailsWith<ConnectException> { Socket("127.0.0.1", short.port).close() }
120+
assertTrue(short.log.readText().contains("refusing to start"), short.log.readText())
121+
} finally {
122+
short.process.destroyForcibly()
123+
}
124+
}
125+
91126
private class Probe(val process: Process, val port: Int, val log: File) {
92127
fun awaitLine(line: String) {
93128
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(120)
@@ -100,21 +135,28 @@ class PythonDevAgentTest {
100135
}
101136
}
102137

103-
private fun launchProbe(request: Boolean): Probe {
138+
/**
139+
* [debugBuild] stages a payload as toolchain does for a debug variant: a `python/` root on the class path
140+
* with a module and the marker. A release variant's payload is the same without the marker.
141+
*/
142+
private fun launchProbe(request: Boolean, debugBuild: Boolean, token: String = this.token): Probe {
104143
val port = ServerSocket(0).use { it.localPort }
105144
val workDir = Files.createTempDirectory("pmp-devagent-probe").toFile()
106145
val log = File(workDir, "probe.log")
146+
val payload = File(workDir, "classes/${PythonPayload.PAYLOAD_ROOT}").apply { mkdirs() }
147+
File(payload, "devagent_probe_app.py").writeText("VALUE = 1\n")
148+
if (debugBuild) File(payload, PythonDevAgent.ENABLE_MARKER).writeText("")
107149
val command = listOf(
108150
File(System.getProperty("java.home"), "bin/java").path,
109151
"--enable-preview",
110152
"--enable-native-access=ALL-UNNAMED",
111153
"-cp",
112-
System.getProperty("java.class.path"),
154+
File(workDir, "classes").path + File.pathSeparator + System.getProperty("java.class.path"),
113155
DevAgentProbe::class.java.name,
114156
)
115157
val builder = ProcessBuilder(command).directory(workDir).redirectErrorStream(true).redirectOutput(log)
116158
builder.environment().remove(PythonDevAgent.ENVIRONMENT_VARIABLE)
117-
if (request) builder.environment()[PythonDevAgent.ENVIRONMENT_VARIABLE] = "port=$port;token=launch-token"
159+
if (request) builder.environment()[PythonDevAgent.ENVIRONMENT_VARIABLE] = "port=$port;token=$token"
118160
return Probe(builder.start(), port, log)
119161
}
120162

0 commit comments

Comments
 (0)