procsock 0.1.0a0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jifeng Wu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,204 @@
1
+ Metadata-Version: 2.4
2
+ Name: procsock
3
+ Version: 0.1.0a0
4
+ Summary: A small tool for running and tracking background processes over a local TCP port.
5
+ Author-email: Jifeng Wu <jifengwu2k@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/jifengwu2k/procsock
8
+ Project-URL: Bug Tracker, https://github.com/jifengwu2k/procsock/issues
9
+ Classifier: Programming Language :: Python :: 2
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=2
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: ctypes-unicode-proclaunch
16
+ Requires-Dist: read-unicode-environment-variables-dictionary
17
+ Requires-Dist: send-recv-json
18
+ Requires-Dist: typing; python_version < "3.5"
19
+ Dynamic: license-file
20
+
21
+ # procsock
22
+
23
+ A small tool for running and tracking background processes over a local TCP port.
24
+
25
+ It is **not** a terminal multiplexer and does **not** use PTYs. It launches plain child processes, redirects their stdin/stdout/stderr to files, and lets clients query whether a process is still running or has finished.
26
+
27
+ ## Overview
28
+
29
+ `procsock` consists of two pieces:
30
+
31
+ - a **server**
32
+ - a **client** that connects over a local TCP port when the server is available
33
+
34
+ The server accepts JSON-RPC requests to start and inspect processes. Each process is started with:
35
+
36
+ - argv
37
+ - client's current working directory
38
+ - client's current environment
39
+ - optional stdin file
40
+ - optional stdout file
41
+ - optional stderr file
42
+
43
+ When not specified, stdin/stdout/stderr default to `os.devnull`.
44
+
45
+ The launcher returns a process identifier:
46
+
47
+ - on Unix, it is the process ID
48
+ - on Windows/NT, it is the process handle
49
+
50
+ The server keeps the process state in memory only. If the server exits or restarts, all state is lost.
51
+
52
+ ## Example session
53
+
54
+ Start server:
55
+
56
+ ```bash
57
+ procsock server --port 9000
58
+ ```
59
+
60
+ Launch process:
61
+
62
+ ```bash
63
+ procsock launch \
64
+ --port 9000 \
65
+ --stdin /tmp/in.txt \
66
+ --stdout /tmp/out.txt \
67
+ --stderr /tmp/err.txt \
68
+ -- /usr/bin/python3 -c 'print("hello")'
69
+ ```
70
+
71
+ List:
72
+
73
+ ```bash
74
+ procsock list --port 9000
75
+ ```
76
+
77
+ Terminate:
78
+
79
+ ```bash
80
+ procsock terminate --port 9000 12345
81
+ ```
82
+
83
+ ## How it works
84
+
85
+ 1. Start the server in the foreground.
86
+ 2. The server binds a local TCP port.
87
+ 3. A client connects and sends JSON-RPC commands.
88
+ 4. The server launches child processes with `ctypes-unicode-proclaunch`.
89
+ 5. Each launched process is started from a dedicated launcher thread.
90
+ 6. That launcher thread uses the client's current working directory as the process working directory.
91
+ 7. This is implemented with a small synchronized `TempCwd` helper class exposing `__enter__` and `__exit__`.
92
+ 8. A waiter thread waits for process completion and updates the in-memory process status on exit.
93
+ 9. The client can list the status of all processes or terminate a process.
94
+ 10. If the server exits, managed children are terminated, and the in-memory process state is lost.
95
+
96
+ ## Commands
97
+
98
+ ### `launch`
99
+
100
+ Launch a new child process via `ctypes-unicode-proclaunch`.
101
+
102
+ For the CLI, the launched process inherits the current environment of the `procsock launch` client process.
103
+
104
+ For each launched process:
105
+
106
+ - stdin is opened from the requested file path, typically read-only
107
+ - stdout is opened to the requested file path
108
+ - stderr is opened to the requested file path
109
+ - any unspecified stdin/stdout/stderr path defaults to `os.devnull`
110
+
111
+ Behavior should match normal process redirection semantics:
112
+
113
+ - stdin: if a path is provided, it must already exist
114
+ - stdout: if a path is provided, open with create/truncate behavior
115
+ - stderr: if a path is provided, open with create/truncate behavior
116
+ - parent directories are not created automatically
117
+
118
+ Request:
119
+
120
+ ```json
121
+ {
122
+ "jsonrpc": "2.0",
123
+ "id": 1,
124
+ "method": "launch",
125
+ "params": {
126
+ "argv": ["/bin/sh", "-c", "echo hello; sleep 2"],
127
+ "cwd": "/tmp",
128
+ "stdin_path": "/tmp/in.txt",
129
+ "stdout_path": "/tmp/out.txt",
130
+ "stderr_path": "/tmp/err.txt"
131
+ }
132
+ }
133
+ ```
134
+
135
+ Response:
136
+
137
+ ```json
138
+ {
139
+ "jsonrpc": "2.0",
140
+ "id": 1,
141
+ "result": {
142
+ "pid": 12345,
143
+ "argv": ["/bin/sh", "-c", "echo hello; sleep 2"],
144
+ "cwd": "/tmp",
145
+ "stdin_path": "/tmp/in.txt",
146
+ "stdout_path": "/tmp/out.txt",
147
+ "stderr_path": "/tmp/err.txt",
148
+ "finished": false,
149
+ "started_at": 1760000000.0,
150
+ "finished_at": null,
151
+ "exit_code": null
152
+ }
153
+ }
154
+ ```
155
+
156
+ ### `list`
157
+
158
+ Return the status of all known in-memory processes.
159
+
160
+ Request:
161
+
162
+ ```json
163
+ {
164
+ "jsonrpc": "2.0",
165
+ "id": 2,
166
+ "method": "list",
167
+ "params": {}
168
+ }
169
+ ```
170
+
171
+ Response:
172
+
173
+ ```json
174
+ {
175
+ "jsonrpc": "2.0",
176
+ "id": 2,
177
+ "result": [
178
+ {
179
+ "pid": 12345,
180
+ "argv": ["/bin/sh", "-c", "echo hello; sleep 2"],
181
+ "cwd": "/tmp",
182
+ "stdin_path": "/tmp/in.txt",
183
+ "stdout_path": "/tmp/out.txt",
184
+ "stderr_path": "/tmp/err.txt",
185
+ "finished": false,
186
+ "started_at": 1760000000.0,
187
+ "finished_at": null,
188
+ "exit_code": null
189
+ }
190
+ ]
191
+ }
192
+ ```
193
+
194
+ ### `terminate`
195
+
196
+ Terminate a process with `SIGTERM`.
197
+
198
+ ## Contributing
199
+
200
+ Contributions are welcome! Please submit pull requests or open issues on the GitHub repository.
201
+
202
+ ## License
203
+
204
+ This project is licensed under the [MIT License](LICENSE).
@@ -0,0 +1,184 @@
1
+ # procsock
2
+
3
+ A small tool for running and tracking background processes over a local TCP port.
4
+
5
+ It is **not** a terminal multiplexer and does **not** use PTYs. It launches plain child processes, redirects their stdin/stdout/stderr to files, and lets clients query whether a process is still running or has finished.
6
+
7
+ ## Overview
8
+
9
+ `procsock` consists of two pieces:
10
+
11
+ - a **server**
12
+ - a **client** that connects over a local TCP port when the server is available
13
+
14
+ The server accepts JSON-RPC requests to start and inspect processes. Each process is started with:
15
+
16
+ - argv
17
+ - client's current working directory
18
+ - client's current environment
19
+ - optional stdin file
20
+ - optional stdout file
21
+ - optional stderr file
22
+
23
+ When not specified, stdin/stdout/stderr default to `os.devnull`.
24
+
25
+ The launcher returns a process identifier:
26
+
27
+ - on Unix, it is the process ID
28
+ - on Windows/NT, it is the process handle
29
+
30
+ The server keeps the process state in memory only. If the server exits or restarts, all state is lost.
31
+
32
+ ## Example session
33
+
34
+ Start server:
35
+
36
+ ```bash
37
+ procsock server --port 9000
38
+ ```
39
+
40
+ Launch process:
41
+
42
+ ```bash
43
+ procsock launch \
44
+ --port 9000 \
45
+ --stdin /tmp/in.txt \
46
+ --stdout /tmp/out.txt \
47
+ --stderr /tmp/err.txt \
48
+ -- /usr/bin/python3 -c 'print("hello")'
49
+ ```
50
+
51
+ List:
52
+
53
+ ```bash
54
+ procsock list --port 9000
55
+ ```
56
+
57
+ Terminate:
58
+
59
+ ```bash
60
+ procsock terminate --port 9000 12345
61
+ ```
62
+
63
+ ## How it works
64
+
65
+ 1. Start the server in the foreground.
66
+ 2. The server binds a local TCP port.
67
+ 3. A client connects and sends JSON-RPC commands.
68
+ 4. The server launches child processes with `ctypes-unicode-proclaunch`.
69
+ 5. Each launched process is started from a dedicated launcher thread.
70
+ 6. That launcher thread uses the client's current working directory as the process working directory.
71
+ 7. This is implemented with a small synchronized `TempCwd` helper class exposing `__enter__` and `__exit__`.
72
+ 8. A waiter thread waits for process completion and updates the in-memory process status on exit.
73
+ 9. The client can list the status of all processes or terminate a process.
74
+ 10. If the server exits, managed children are terminated, and the in-memory process state is lost.
75
+
76
+ ## Commands
77
+
78
+ ### `launch`
79
+
80
+ Launch a new child process via `ctypes-unicode-proclaunch`.
81
+
82
+ For the CLI, the launched process inherits the current environment of the `procsock launch` client process.
83
+
84
+ For each launched process:
85
+
86
+ - stdin is opened from the requested file path, typically read-only
87
+ - stdout is opened to the requested file path
88
+ - stderr is opened to the requested file path
89
+ - any unspecified stdin/stdout/stderr path defaults to `os.devnull`
90
+
91
+ Behavior should match normal process redirection semantics:
92
+
93
+ - stdin: if a path is provided, it must already exist
94
+ - stdout: if a path is provided, open with create/truncate behavior
95
+ - stderr: if a path is provided, open with create/truncate behavior
96
+ - parent directories are not created automatically
97
+
98
+ Request:
99
+
100
+ ```json
101
+ {
102
+ "jsonrpc": "2.0",
103
+ "id": 1,
104
+ "method": "launch",
105
+ "params": {
106
+ "argv": ["/bin/sh", "-c", "echo hello; sleep 2"],
107
+ "cwd": "/tmp",
108
+ "stdin_path": "/tmp/in.txt",
109
+ "stdout_path": "/tmp/out.txt",
110
+ "stderr_path": "/tmp/err.txt"
111
+ }
112
+ }
113
+ ```
114
+
115
+ Response:
116
+
117
+ ```json
118
+ {
119
+ "jsonrpc": "2.0",
120
+ "id": 1,
121
+ "result": {
122
+ "pid": 12345,
123
+ "argv": ["/bin/sh", "-c", "echo hello; sleep 2"],
124
+ "cwd": "/tmp",
125
+ "stdin_path": "/tmp/in.txt",
126
+ "stdout_path": "/tmp/out.txt",
127
+ "stderr_path": "/tmp/err.txt",
128
+ "finished": false,
129
+ "started_at": 1760000000.0,
130
+ "finished_at": null,
131
+ "exit_code": null
132
+ }
133
+ }
134
+ ```
135
+
136
+ ### `list`
137
+
138
+ Return the status of all known in-memory processes.
139
+
140
+ Request:
141
+
142
+ ```json
143
+ {
144
+ "jsonrpc": "2.0",
145
+ "id": 2,
146
+ "method": "list",
147
+ "params": {}
148
+ }
149
+ ```
150
+
151
+ Response:
152
+
153
+ ```json
154
+ {
155
+ "jsonrpc": "2.0",
156
+ "id": 2,
157
+ "result": [
158
+ {
159
+ "pid": 12345,
160
+ "argv": ["/bin/sh", "-c", "echo hello; sleep 2"],
161
+ "cwd": "/tmp",
162
+ "stdin_path": "/tmp/in.txt",
163
+ "stdout_path": "/tmp/out.txt",
164
+ "stderr_path": "/tmp/err.txt",
165
+ "finished": false,
166
+ "started_at": 1760000000.0,
167
+ "finished_at": null,
168
+ "exit_code": null
169
+ }
170
+ ]
171
+ }
172
+ ```
173
+
174
+ ### `terminate`
175
+
176
+ Terminate a process with `SIGTERM`.
177
+
178
+ ## Contributing
179
+
180
+ Contributions are welcome! Please submit pull requests or open issues on the GitHub repository.
181
+
182
+ ## License
183
+
184
+ This project is licensed under the [MIT License](LICENSE).
@@ -0,0 +1,204 @@
1
+ Metadata-Version: 2.4
2
+ Name: procsock
3
+ Version: 0.1.0a0
4
+ Summary: A small tool for running and tracking background processes over a local TCP port.
5
+ Author-email: Jifeng Wu <jifengwu2k@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/jifengwu2k/procsock
8
+ Project-URL: Bug Tracker, https://github.com/jifengwu2k/procsock/issues
9
+ Classifier: Programming Language :: Python :: 2
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=2
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: ctypes-unicode-proclaunch
16
+ Requires-Dist: read-unicode-environment-variables-dictionary
17
+ Requires-Dist: send-recv-json
18
+ Requires-Dist: typing; python_version < "3.5"
19
+ Dynamic: license-file
20
+
21
+ # procsock
22
+
23
+ A small tool for running and tracking background processes over a local TCP port.
24
+
25
+ It is **not** a terminal multiplexer and does **not** use PTYs. It launches plain child processes, redirects their stdin/stdout/stderr to files, and lets clients query whether a process is still running or has finished.
26
+
27
+ ## Overview
28
+
29
+ `procsock` consists of two pieces:
30
+
31
+ - a **server**
32
+ - a **client** that connects over a local TCP port when the server is available
33
+
34
+ The server accepts JSON-RPC requests to start and inspect processes. Each process is started with:
35
+
36
+ - argv
37
+ - client's current working directory
38
+ - client's current environment
39
+ - optional stdin file
40
+ - optional stdout file
41
+ - optional stderr file
42
+
43
+ When not specified, stdin/stdout/stderr default to `os.devnull`.
44
+
45
+ The launcher returns a process identifier:
46
+
47
+ - on Unix, it is the process ID
48
+ - on Windows/NT, it is the process handle
49
+
50
+ The server keeps the process state in memory only. If the server exits or restarts, all state is lost.
51
+
52
+ ## Example session
53
+
54
+ Start server:
55
+
56
+ ```bash
57
+ procsock server --port 9000
58
+ ```
59
+
60
+ Launch process:
61
+
62
+ ```bash
63
+ procsock launch \
64
+ --port 9000 \
65
+ --stdin /tmp/in.txt \
66
+ --stdout /tmp/out.txt \
67
+ --stderr /tmp/err.txt \
68
+ -- /usr/bin/python3 -c 'print("hello")'
69
+ ```
70
+
71
+ List:
72
+
73
+ ```bash
74
+ procsock list --port 9000
75
+ ```
76
+
77
+ Terminate:
78
+
79
+ ```bash
80
+ procsock terminate --port 9000 12345
81
+ ```
82
+
83
+ ## How it works
84
+
85
+ 1. Start the server in the foreground.
86
+ 2. The server binds a local TCP port.
87
+ 3. A client connects and sends JSON-RPC commands.
88
+ 4. The server launches child processes with `ctypes-unicode-proclaunch`.
89
+ 5. Each launched process is started from a dedicated launcher thread.
90
+ 6. That launcher thread uses the client's current working directory as the process working directory.
91
+ 7. This is implemented with a small synchronized `TempCwd` helper class exposing `__enter__` and `__exit__`.
92
+ 8. A waiter thread waits for process completion and updates the in-memory process status on exit.
93
+ 9. The client can list the status of all processes or terminate a process.
94
+ 10. If the server exits, managed children are terminated, and the in-memory process state is lost.
95
+
96
+ ## Commands
97
+
98
+ ### `launch`
99
+
100
+ Launch a new child process via `ctypes-unicode-proclaunch`.
101
+
102
+ For the CLI, the launched process inherits the current environment of the `procsock launch` client process.
103
+
104
+ For each launched process:
105
+
106
+ - stdin is opened from the requested file path, typically read-only
107
+ - stdout is opened to the requested file path
108
+ - stderr is opened to the requested file path
109
+ - any unspecified stdin/stdout/stderr path defaults to `os.devnull`
110
+
111
+ Behavior should match normal process redirection semantics:
112
+
113
+ - stdin: if a path is provided, it must already exist
114
+ - stdout: if a path is provided, open with create/truncate behavior
115
+ - stderr: if a path is provided, open with create/truncate behavior
116
+ - parent directories are not created automatically
117
+
118
+ Request:
119
+
120
+ ```json
121
+ {
122
+ "jsonrpc": "2.0",
123
+ "id": 1,
124
+ "method": "launch",
125
+ "params": {
126
+ "argv": ["/bin/sh", "-c", "echo hello; sleep 2"],
127
+ "cwd": "/tmp",
128
+ "stdin_path": "/tmp/in.txt",
129
+ "stdout_path": "/tmp/out.txt",
130
+ "stderr_path": "/tmp/err.txt"
131
+ }
132
+ }
133
+ ```
134
+
135
+ Response:
136
+
137
+ ```json
138
+ {
139
+ "jsonrpc": "2.0",
140
+ "id": 1,
141
+ "result": {
142
+ "pid": 12345,
143
+ "argv": ["/bin/sh", "-c", "echo hello; sleep 2"],
144
+ "cwd": "/tmp",
145
+ "stdin_path": "/tmp/in.txt",
146
+ "stdout_path": "/tmp/out.txt",
147
+ "stderr_path": "/tmp/err.txt",
148
+ "finished": false,
149
+ "started_at": 1760000000.0,
150
+ "finished_at": null,
151
+ "exit_code": null
152
+ }
153
+ }
154
+ ```
155
+
156
+ ### `list`
157
+
158
+ Return the status of all known in-memory processes.
159
+
160
+ Request:
161
+
162
+ ```json
163
+ {
164
+ "jsonrpc": "2.0",
165
+ "id": 2,
166
+ "method": "list",
167
+ "params": {}
168
+ }
169
+ ```
170
+
171
+ Response:
172
+
173
+ ```json
174
+ {
175
+ "jsonrpc": "2.0",
176
+ "id": 2,
177
+ "result": [
178
+ {
179
+ "pid": 12345,
180
+ "argv": ["/bin/sh", "-c", "echo hello; sleep 2"],
181
+ "cwd": "/tmp",
182
+ "stdin_path": "/tmp/in.txt",
183
+ "stdout_path": "/tmp/out.txt",
184
+ "stderr_path": "/tmp/err.txt",
185
+ "finished": false,
186
+ "started_at": 1760000000.0,
187
+ "finished_at": null,
188
+ "exit_code": null
189
+ }
190
+ ]
191
+ }
192
+ ```
193
+
194
+ ### `terminate`
195
+
196
+ Terminate a process with `SIGTERM`.
197
+
198
+ ## Contributing
199
+
200
+ Contributions are welcome! Please submit pull requests or open issues on the GitHub repository.
201
+
202
+ ## License
203
+
204
+ This project is licensed under the [MIT License](LICENSE).
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ procsock.py
4
+ pyproject.toml
5
+ setup.cfg
6
+ procsock.egg-info/PKG-INFO
7
+ procsock.egg-info/SOURCES.txt
8
+ procsock.egg-info/dependency_links.txt
9
+ procsock.egg-info/entry_points.txt
10
+ procsock.egg-info/requires.txt
11
+ procsock.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ procsock = procsock:main
@@ -0,0 +1,6 @@
1
+ ctypes-unicode-proclaunch
2
+ read-unicode-environment-variables-dictionary
3
+ send-recv-json
4
+
5
+ [:python_version < "3.5"]
6
+ typing
@@ -0,0 +1 @@
1
+ procsock
@@ -0,0 +1,537 @@
1
+ # Copyright (c) 2025 Jifeng Wu
2
+ # Licensed under the MIT License. See LICENSE file in the project root for full license information.
3
+ #!/usr/bin/env python
4
+
5
+ import argparse
6
+ import json
7
+ import logging
8
+ import os
9
+ import signal
10
+ import socket
11
+ import sys
12
+ import threading
13
+ import time
14
+ from itertools import count
15
+ from typing import Any, Dict, List, Optional, Set, Tuple
16
+
17
+ import ctypes_unicode_proclaunch
18
+ from read_unicode_environment_variables_dictionary import (
19
+ read_unicode_environment_variables_dictionary,
20
+ )
21
+ from send_recv_json import recv_json, send_json
22
+
23
+ JSONRPC_VERSION = "2.0"
24
+ DEFAULT_HOST = "localhost"
25
+
26
+
27
+ class UnknownPidError(Exception):
28
+ pass
29
+
30
+
31
+ class TempCwd(object):
32
+ __slots__ = ("cwd", "previous_cwd")
33
+
34
+ _lock = threading.RLock()
35
+
36
+ def __init__(self, cwd):
37
+ # type: (str) -> None
38
+ self.cwd = cwd # type: str
39
+ self.previous_cwd = None # type: Optional[str]
40
+
41
+ def __enter__(self):
42
+ # type: () -> "TempCwd"
43
+ self._lock.acquire()
44
+ try:
45
+ self.previous_cwd = os.getcwd()
46
+ os.chdir(self.cwd)
47
+ return self
48
+ except Exception:
49
+ self._lock.release()
50
+ raise
51
+
52
+ def __exit__(self, exc_type, exc, tb):
53
+ # type: (Any, Any, Any) -> None
54
+ try:
55
+ if self.previous_cwd is not None:
56
+ os.chdir(self.previous_cwd)
57
+ finally:
58
+ self._lock.release()
59
+
60
+
61
+ class Process(object):
62
+ __slots__ = (
63
+ "pid",
64
+ "argv",
65
+ "cwd",
66
+ "stdin_path",
67
+ "stdout_path",
68
+ "stderr_path",
69
+ "finished",
70
+ "started_at",
71
+ "finished_at",
72
+ "exit_code",
73
+ "wait_thread",
74
+ )
75
+
76
+ def __init__(
77
+ self,
78
+ pid,
79
+ argv,
80
+ cwd,
81
+ stdin_path,
82
+ stdout_path,
83
+ stderr_path,
84
+ finished,
85
+ started_at,
86
+ finished_at,
87
+ exit_code,
88
+ wait_thread=None,
89
+ ):
90
+ # type: (int, List[str], str, str, str, str, bool, float, Optional[float], Optional[int], Optional[threading.Thread]) -> None
91
+ self.pid = pid # type: int
92
+ self.argv = argv # type: List[str]
93
+ self.cwd = cwd # type: str
94
+ self.stdin_path = stdin_path # type: str
95
+ self.stdout_path = stdout_path # type: str
96
+ self.stderr_path = stderr_path # type: str
97
+ self.finished = finished # type: bool
98
+ self.started_at = started_at # type: float
99
+ self.finished_at = finished_at # type: Optional[float]
100
+ self.exit_code = exit_code # type: Optional[int]
101
+ self.wait_thread = wait_thread # type: Optional[threading.Thread]
102
+
103
+ def to_dict(self):
104
+ # type: () -> Dict[str, Any]
105
+ return {
106
+ "pid": self.pid,
107
+ "argv": self.argv,
108
+ "cwd": self.cwd,
109
+ "stdin_path": self.stdin_path,
110
+ "stdout_path": self.stdout_path,
111
+ "stderr_path": self.stderr_path,
112
+ "finished": self.finished,
113
+ "started_at": self.started_at,
114
+ "finished_at": self.finished_at,
115
+ "exit_code": self.exit_code,
116
+ }
117
+
118
+
119
+ class ProcessTable(object):
120
+ __slots__ = ("_lock", "_processes")
121
+
122
+ def __init__(self):
123
+ # type: () -> None
124
+ self._lock = threading.RLock()
125
+ self._processes = {} # type: Dict[int, Process]
126
+
127
+ def add(self, process):
128
+ # type: (Process) -> None
129
+ with self._lock:
130
+ if process.pid in self._processes:
131
+ raise RuntimeError("pid already tracked: {}".format(process.pid))
132
+ self._processes[process.pid] = process
133
+
134
+ def get(self, pid):
135
+ # type: (int) -> Process
136
+ with self._lock:
137
+ try:
138
+ return self._processes[pid]
139
+ except KeyError as exc:
140
+ raise UnknownPidError("unknown pid: {}".format(pid))
141
+
142
+ def list(self):
143
+ # type: () -> List[Process]
144
+ with self._lock:
145
+ return sorted(
146
+ self._processes.values(),
147
+ key=lambda process: (process.started_at, process.pid),
148
+ )
149
+
150
+ def mark_finished(self, pid, exit_code):
151
+ # type: (int, Optional[int]) -> None
152
+ with self._lock:
153
+ process = self._processes.get(pid)
154
+ if process is None:
155
+ return
156
+ process.finished = True
157
+ process.finished_at = time.time()
158
+ process.exit_code = exit_code
159
+
160
+ def unfinished(self):
161
+ # type: () -> List[Process]
162
+ with self._lock:
163
+ return [
164
+ process for process in self._processes.values() if not process.finished
165
+ ]
166
+
167
+
168
+ class ProcSockServer(object):
169
+ __slots__ = (
170
+ "host",
171
+ "port",
172
+ "processes",
173
+ "stop_event",
174
+ "server_socket",
175
+ "connection_threads",
176
+ "connection_threads_lock",
177
+ )
178
+
179
+ def __init__(self, host, port):
180
+ # type: (str, int) -> None
181
+ self.host = host # type: str
182
+ self.port = port # type: int
183
+ self.processes = ProcessTable()
184
+ self.stop_event = threading.Event()
185
+ self.server_socket = None # type: Optional[socket.socket]
186
+ self.connection_threads = set() # type: Set[threading.Thread]
187
+ self.connection_threads_lock = threading.Lock()
188
+
189
+ def serve_forever(self):
190
+ # type: () -> None
191
+ self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
192
+ self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
193
+ self.server_socket.bind((self.host, self.port))
194
+ self.server_socket.listen()
195
+ logging.info("listening on %s:%s", self.host, self.port)
196
+
197
+ self._install_signal_handlers()
198
+
199
+ try:
200
+ while not self.stop_event.is_set():
201
+ try:
202
+ conn, addr = self.server_socket.accept()
203
+ except OSError:
204
+ if self.stop_event.is_set():
205
+ break
206
+ raise
207
+ thread = threading.Thread(
208
+ target=self._handle_connection, args=(conn, addr), daemon=True
209
+ )
210
+ with self.connection_threads_lock:
211
+ self.connection_threads.add(thread)
212
+ thread.start()
213
+ finally:
214
+ self.shutdown()
215
+
216
+ def _install_signal_handlers(self):
217
+ # type: () -> None
218
+ def handler(signum, frame):
219
+ # type: (int, Any) -> None
220
+ logging.info("received signal %s, shutting down", signum)
221
+ self.stop_event.set()
222
+ if self.server_socket is not None:
223
+ try:
224
+ self.server_socket.close()
225
+ except OSError:
226
+ pass
227
+
228
+ for signum in (signal.SIGINT, signal.SIGTERM):
229
+ signal.signal(signum, handler)
230
+
231
+ def shutdown(self):
232
+ # type: () -> None
233
+ if self.stop_event.is_set():
234
+ pass
235
+ else:
236
+ self.stop_event.set()
237
+ if self.server_socket is not None:
238
+ try:
239
+ self.server_socket.close()
240
+ except OSError:
241
+ pass
242
+ self.server_socket = None
243
+
244
+ unfinished = self.processes.unfinished()
245
+ for process in unfinished:
246
+ try:
247
+ ctypes_unicode_proclaunch.terminate(process.pid)
248
+ except Exception as exc: # noqa: BLE001
249
+ logging.warning("failed to terminate pid=%s: %s", process.pid, exc)
250
+
251
+ for process in unfinished:
252
+ thread = process.wait_thread
253
+ if thread is None:
254
+ continue
255
+ thread.join()
256
+
257
+ def _handle_connection(self, conn, addr):
258
+ # type: (socket.socket, Tuple[str, int]) -> None
259
+ request = None # type: Any
260
+ try:
261
+ with conn:
262
+ try:
263
+ request = recv_json(conn.recv)
264
+ response = self._dispatch_request(request)
265
+ except Exception as exc: # noqa: BLE001
266
+ logging.warning(
267
+ "request handling failed from %s: %s: %s",
268
+ addr,
269
+ type(exc).__name__,
270
+ exc,
271
+ )
272
+ response = make_error_response(
273
+ request_id=(
274
+ request.get("id") if isinstance(request, dict) else None
275
+ ),
276
+ exc=exc,
277
+ )
278
+ send_json(conn.send, response)
279
+ finally:
280
+ with self.connection_threads_lock:
281
+ thread = threading.current_thread()
282
+ self.connection_threads.discard(thread)
283
+
284
+ def _dispatch_request(self, request):
285
+ # type: (Any) -> Dict[str, Any]
286
+ request_id = request["id"]
287
+ method = request["method"]
288
+ params = request.get("params", {})
289
+
290
+ if method == "launch":
291
+ result = self.launch(
292
+ params["argv"],
293
+ params["cwd"],
294
+ params.get("stdin_path", os.devnull),
295
+ params.get("stdout_path", os.devnull),
296
+ params.get("stderr_path", os.devnull),
297
+ params.get("env"),
298
+ )
299
+ elif method == "list":
300
+ result = self.list_processes()
301
+ elif method == "terminate":
302
+ result = self.terminate(params["pid"])
303
+ else:
304
+ raise ValueError("unknown method: {}".format(method))
305
+
306
+ return {"jsonrpc": JSONRPC_VERSION, "id": request_id, "result": result}
307
+
308
+ def launch(self, argv, cwd, stdin_path, stdout_path, stderr_path, env):
309
+ # type: (List[str], str, str, str, str, Optional[Dict[str, str]]) -> Dict[str, Any]
310
+ ready = threading.Event()
311
+ outcome = {} # type: Dict[str, Any]
312
+
313
+ def launcher_target():
314
+ # type: () -> None
315
+ try:
316
+ pid = self._launch_process(
317
+ argv,
318
+ cwd,
319
+ stdin_path,
320
+ stdout_path,
321
+ stderr_path,
322
+ env,
323
+ )
324
+ outcome["pid"] = pid
325
+ except Exception as exc: # noqa: BLE001
326
+ outcome["exception"] = exc
327
+ finally:
328
+ ready.set()
329
+
330
+ launcher_thread = threading.Thread(target=launcher_target, daemon=True)
331
+ launcher_thread.start()
332
+ ready.wait()
333
+
334
+ if "exception" in outcome:
335
+ raise outcome["exception"]
336
+
337
+ process = self.processes.get(outcome["pid"])
338
+ return process.to_dict()
339
+
340
+ def _launch_process(self, argv, cwd, stdin_path, stdout_path, stderr_path, env):
341
+ # type: (List[str], str, str, str, str, Optional[Dict[str, str]]) -> int
342
+ stdin_file = None # type: Any
343
+ stdout_file = None # type: Any
344
+ stderr_file = None # type: Any
345
+ try:
346
+ stdin_file = open(stdin_path, "rb")
347
+ stdout_file = open(stdout_path, "wb")
348
+ stderr_file = open(stderr_path, "wb")
349
+
350
+ with TempCwd(cwd):
351
+ raw_pid = ctypes_unicode_proclaunch.launch(
352
+ argv,
353
+ environment=env,
354
+ stdin_file_descriptor=stdin_file.fileno(),
355
+ stdout_file_descriptor=stdout_file.fileno(),
356
+ stderr_file_descriptor=stderr_file.fileno(),
357
+ )
358
+
359
+ pid = int(raw_pid)
360
+ started_at = time.time()
361
+ process = Process(
362
+ pid=pid,
363
+ argv=list(argv),
364
+ cwd=cwd,
365
+ stdin_path=stdin_path,
366
+ stdout_path=stdout_path,
367
+ stderr_path=stderr_path,
368
+ finished=False,
369
+ started_at=started_at,
370
+ finished_at=None,
371
+ exit_code=None,
372
+ )
373
+ wait_thread = threading.Thread(
374
+ target=self._wait_for_process,
375
+ args=(pid,),
376
+ daemon=True,
377
+ name="wait-pid-{}".format(pid),
378
+ )
379
+ process.wait_thread = wait_thread
380
+ self.processes.add(process)
381
+ wait_thread.start()
382
+ logging.info("launched pid=%s argv=%r cwd=%s", pid, argv, cwd)
383
+ return pid
384
+ finally:
385
+ for file_obj in (stdin_file, stdout_file, stderr_file):
386
+ if file_obj is not None:
387
+ file_obj.close()
388
+
389
+ def _wait_for_process(self, pid):
390
+ # type: (int) -> None
391
+ try:
392
+ exit_code = int(ctypes_unicode_proclaunch.wait(pid))
393
+ self.processes.mark_finished(pid, exit_code)
394
+ logging.info("process finished pid=%s exit_code=%s", pid, exit_code)
395
+ except Exception as exc: # noqa: BLE001
396
+ logging.exception("wait failed for pid=%s", pid)
397
+ self.processes.mark_finished(pid, None)
398
+
399
+ def list_processes(self):
400
+ # type: () -> List[Dict[str, Any]]
401
+ return [process.to_dict() for process in self.processes.list()]
402
+
403
+ def terminate(self, pid):
404
+ # type: (int) -> Dict[str, Any]
405
+ process = self.processes.get(pid)
406
+ if not process.finished:
407
+ ctypes_unicode_proclaunch.terminate(process.pid)
408
+ logging.info("terminate requested pid=%s", process.pid)
409
+ return process.to_dict()
410
+
411
+
412
+ def make_error_response(request_id, exc):
413
+ # type: (Any, Exception) -> Dict[str, Any]
414
+ return {
415
+ "jsonrpc": JSONRPC_VERSION,
416
+ "id": request_id,
417
+ "error": {
418
+ "type": type(exc).__name__,
419
+ "message": str(exc),
420
+ },
421
+ }
422
+
423
+
424
+ _REQUEST_IDS = count(1)
425
+
426
+
427
+ def send_jsonrpc_request(host, port, method, params):
428
+ # type: (str, int, str, Dict[str, Any]) -> Any
429
+ request = {
430
+ "jsonrpc": JSONRPC_VERSION,
431
+ "id": next(_REQUEST_IDS),
432
+ "method": method,
433
+ "params": params,
434
+ }
435
+ with socket.create_connection((host, port)) as conn:
436
+ send_json(conn.send, request)
437
+ response = recv_json(conn.recv)
438
+ if "error" in response:
439
+ error = response["error"]
440
+ error_type = error.get("type", "Exception")
441
+ error_message = error.get("message", "")
442
+ raise RuntimeError("{}: {}".format(error_type, error_message))
443
+ return response["result"]
444
+
445
+
446
+ def request_launch(host, port, argv, cwd, stdin_path, stdout_path, stderr_path, env):
447
+ # type: (str, int, List[str], str, str, str, str, Dict[str, str]) -> Any
448
+ return send_jsonrpc_request(
449
+ host,
450
+ port,
451
+ "launch",
452
+ {
453
+ "argv": argv,
454
+ "cwd": cwd,
455
+ "stdin_path": stdin_path,
456
+ "stdout_path": stdout_path,
457
+ "stderr_path": stderr_path,
458
+ "env": env,
459
+ },
460
+ )
461
+
462
+
463
+ def request_list(host, port):
464
+ # type: (str, int) -> Any
465
+ return send_jsonrpc_request(host, port, "list", {})
466
+
467
+
468
+ def request_terminate(host, port, pid):
469
+ # type: (str, int, int) -> Any
470
+ return send_jsonrpc_request(host, port, "terminate", {"pid": pid})
471
+
472
+
473
+ def main(argv=None):
474
+ # type: (Optional[List[str]]) -> int
475
+ logging.basicConfig(
476
+ level=logging.INFO,
477
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
478
+ )
479
+
480
+ parser = argparse.ArgumentParser(prog="procsock")
481
+ subparsers = parser.add_subparsers(dest="command")
482
+
483
+ server_parser = subparsers.add_parser("server", help="run the procsock server")
484
+ server_parser.add_argument("--host", default=DEFAULT_HOST)
485
+ server_parser.add_argument("--port", type=int, required=True)
486
+
487
+ launch_parser = subparsers.add_parser("launch", help="launch a process")
488
+ launch_parser.add_argument("--host", default=DEFAULT_HOST)
489
+ launch_parser.add_argument("--port", type=int, required=True)
490
+ launch_parser.add_argument("--cwd", default=None)
491
+ launch_parser.add_argument("--stdin", dest="stdin_path", default=os.devnull)
492
+ launch_parser.add_argument("--stdout", dest="stdout_path", default=os.devnull)
493
+ launch_parser.add_argument("--stderr", dest="stderr_path", default=os.devnull)
494
+ launch_parser.add_argument("argv", nargs=argparse.REMAINDER)
495
+
496
+ list_parser = subparsers.add_parser("list", help="list tracked processes")
497
+ list_parser.add_argument("--host", default=DEFAULT_HOST)
498
+ list_parser.add_argument("--port", type=int, required=True)
499
+
500
+ terminate_parser = subparsers.add_parser("terminate", help="terminate a process")
501
+ terminate_parser.add_argument("--host", default=DEFAULT_HOST)
502
+ terminate_parser.add_argument("--port", type=int, required=True)
503
+ terminate_parser.add_argument("pid", type=int)
504
+ args = parser.parse_args(argv)
505
+
506
+ if args.command == "server":
507
+ server = ProcSockServer(host=args.host, port=args.port)
508
+ server.serve_forever()
509
+
510
+ elif args.command == "launch":
511
+ launch_argv = args.argv[args.argv.index("--") + 1 :]
512
+ if not launch_argv:
513
+ raise ValueError("missing command after '--'")
514
+
515
+ result = request_launch(
516
+ args.host,
517
+ args.port,
518
+ launch_argv,
519
+ args.cwd if args.cwd is not None else os.getcwd(),
520
+ args.stdin_path,
521
+ args.stdout_path,
522
+ args.stderr_path,
523
+ read_unicode_environment_variables_dictionary(),
524
+ )
525
+ print(json.dumps(result, ensure_ascii=False, indent=2))
526
+
527
+ elif args.command == "list":
528
+ result = request_list(args.host, args.port)
529
+ print(json.dumps(result, ensure_ascii=False, indent=2))
530
+
531
+ elif args.command == "terminate":
532
+ result = request_terminate(args.host, args.port, args.pid)
533
+ print(json.dumps(result, ensure_ascii=False, indent=2))
534
+
535
+
536
+ if __name__ == "__main__":
537
+ raise SystemExit(main())
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "procsock"
7
+ version = "0.1.0a0"
8
+ description = "A small tool for running and tracking background processes over a local TCP port."
9
+ readme = "README.md"
10
+ requires-python = ">=2"
11
+ license = "MIT"
12
+ authors = [
13
+ { name="Jifeng Wu", email="jifengwu2k@gmail.com" }
14
+ ]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 2",
17
+ "Programming Language :: Python :: 3",
18
+ "Operating System :: OS Independent",
19
+ ]
20
+ dependencies = [
21
+ "ctypes-unicode-proclaunch",
22
+ "read-unicode-environment-variables-dictionary",
23
+ "send-recv-json",
24
+ "typing; python_version < '3.5'"
25
+ ]
26
+
27
+ [project.scripts]
28
+ procsock = "procsock:main"
29
+
30
+ [project.urls]
31
+ "Homepage" = "https://github.com/jifengwu2k/procsock"
32
+ "Bug Tracker" = "https://github.com/jifengwu2k/procsock/issues"
@@ -0,0 +1,7 @@
1
+ [bdist_wheel]
2
+ universal = 1
3
+
4
+ [egg_info]
5
+ tag_build =
6
+ tag_date = 0
7
+