@twinsearth/w2m-dsh-plugin 0.5.0 → 0.5.1

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,40 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.5.1] — 2026-10-10
8
+
9
+ ### Fixed
10
+
11
+ * **The agent no longer dies with its console.** Measured twice on the fleet: the whole process
12
+ tree — supervisor and agent together — was killed with code `0xC000013A` (`STATUS_CONTROL_C_EXIT`,
13
+ its console went away), the log ended mid-sentence, and Task Scheduler did not restart it, so the
14
+ fleet silently lost the machine. A supervisor that shares the console dies with the thing it
15
+ supervises, which was its one job. The supervisor now spawns the agent with `detached: true` and
16
+ **file-descriptor stdio** — never `inherit`, never pipes, because a detached child writing into a
17
+ pipe whose reader has died blocks instead of failing loudly.
18
+ * **The scheduled task heals itself, without elevation.** The interactive mode now also registers a
19
+ repeating trigger (default every 5 minutes) which, with `MultipleInstances IgnoreNew`, means "be
20
+ running, or start": a death from any cause is repaired at the next tick. It exists because
21
+ `RestartCount 999` was measured not to fire at all.
22
+
23
+ ### Added
24
+
25
+ * **`deploy/networking/LESSONS.md`** — a read-only study of 2dust/v2rayN (GPL-3.0, so description
26
+ with citations only and nothing copied) recording the transport disciplines this project was
27
+ missing, and the decision it changed: **no TUN/VPN mode.** v2rayN never creates an adapter itself
28
+ and contains no peer-to-peer anywhere; a tunnel is hub-and-spoke by construction, so it would make
29
+ our "the data path is peer-to-peer" claim false for exactly the traffic a tunnel exists to carry.
30
+
31
+ ### Notes
32
+
33
+ * A regression introduced and caught during the fix is recorded in the code: the first version also
34
+ called `child.unref()`, which leaves the supervisor with no handle at all, so its event loop
35
+ drains and it exits immediately after spawning the agent. The evidence was that no supervisor
36
+ process existed — found while looking for a different property.
37
+ * Verified on the machine that had the defect: supervisor and agent both present, and killing the
38
+ supervisor leaves the agent alive with its punch socket still bound (`0.0.0.0:41235`).
39
+ * Also included: the test fix for `test/outbound-only.test.mjs`, whose port was being read from the
40
+ timestamp rather than from the field that states it (commit `57dbc5f`).
7
41
  ## [0.5.0] — 2026-10-10
8
42
 
9
43
  The release that stops treating "the relay carried it" as the only answer for a machine behind a
@@ -0,0 +1,90 @@
1
+ # What a mature proxy client taught us about transport — and what it did not
2
+
3
+ This file records a **read-only study of [2dust/v2rayN](https://github.com/2dust/v2rayN)**, done to
4
+ find out which transport disciplines this project is missing. It exists because the study's
5
+ conclusions are only worth anything if they can be checked, and because one of them is a decision we
6
+ would otherwise have made wrongly.
7
+
8
+ ## Provenance and licence
9
+
10
+ * Read at branch **`master`**, commit **`b501efbc45bca3c465df99af7d4ec092c2f4c40f`**. Paths below are
11
+ relative to that clone, so every claim can be checked at that revision.
12
+ * **v2rayN is GPL-3.0** (`LICENSE`, the verbatim GNU GPL v3 text; no other licence statement in the
13
+ repository). **This project is MIT.** No code, text or file was copied from it — not a fragment,
14
+ not a paraphrase of code. Everything below is *description with citations*, and any implementation
15
+ we write from it is our own.
16
+ * We did not modify v2rayN, and we did not open issues or pull requests against it.
17
+
18
+ ## The decision this study changed: do not add a TUN/VPN mode
19
+
20
+ The tempting idea was "add a VPN so two machines behind hard NAT can reach each other". The study
21
+ says plainly that this does not do what it sounds like:
22
+
23
+ * v2rayN never creates an adapter or touches the routing table itself; it writes a declarative config
24
+ and the external core (Xray/sing-box/mihomo) does the privileged work
25
+ (`v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayInboundService.cs:75-77`;
26
+ `Sample/tun_singbox_inbound:10-13`).
27
+ * Its local inbound is loopback-only (`Sample/SampleInbound:5`), and every path is client → remote
28
+ server. There is **no peer-to-peer anywhere in the codebase**: no hole punching, no candidate
29
+ exchange, no relay of our kind — a grep for the concepts returns nothing, and the only STUN code is
30
+ a UDP reachability *diagnostic* (`ServiceLib.UdpTest/Tester/StunService.cs:8-48`), not a data path.
31
+ * So a TUN interface is **hub-and-spoke by construction**: traffic between two of our machines would
32
+ transit the hub. Our promise — "the data path is peer-to-peer" — would become **false for exactly
33
+ the traffic the tunnel exists to carry**.
34
+
35
+ **Therefore: no TUN in this project for now.** It would cost administrator rights, a kernel driver,
36
+ and the truth of our central claim, while solving only part of what the delivery ladder already
37
+ addresses. If it is ever added, two invariants have to hold, and both belong in the ladder's
38
+ documentation rather than in a commit message: the control plane and the punch sockets must be
39
+ exempted from the tunnel, and tunnelled traffic must never be reported as `p2p`.
40
+
41
+ ## Disciplines worth adopting, in the order we should adopt them
42
+
43
+ 1. **Declare policy; let one worker enforce it.** v2rayN's central shape is an ordered, declarative
44
+ policy consumed by a single enforcer (`Services/CoreConfig/…`), with validation that aborts a start
45
+ rather than launching something broken (`Manager/CoreManager.cs:75-80`), mutating a deep copy
46
+ rather than live config. Our ladder should be *data* — ordered rungs, each with its own bound and
47
+ its own reason code — executed in one place.
48
+ 2. **One named constant per network bound, and a bigger budget for the relayed leg.** `LocalFetch`
49
+ 5 s, `DirectDownloadConnect` 5 s, **`ProxyDownloadConnect` 10 s**, selected per hop
50
+ (`Global.cs:100,103-104`; `Helper/DownloaderHelper.cs:336-339`). We cross NAT *and* a relay; the
51
+ relay leg deserves its own larger bound instead of sharing one timeout with a LAN attempt.
52
+ 3. **A distinguished failure value, never a silent zero.** `-1` as a failure sentinel
53
+ (`Handler/ConnectionHandler.cs:34,72`), with dependent work explicitly skipped when a probe fails
54
+ (`Services/SpeedtestService.cs:499-509`). Our `HELLO`/`HELLO_ACK` path should separate *no answer
55
+ yet* / *refused* / *timed out* / *answered wrong*, and the ledger should name **which rung** failed
56
+ rather than reporting a single boolean.
57
+ 4. **Bounded attempts, then fall down the ladder.** `MaxTryAgainOnFailure = 2`, twice with a 500 ms
58
+ gap (`DownloaderHelper.cs:37,71`; `ConnectionHandler.cs:39-47`). Give the simultaneous punch and
59
+ the reverse dial two or three attempts each, and record the reason each one lost.
60
+ 5. **Exempt the transport's own traffic from the transport.** v2rayN forces its cores and its own
61
+ directory to go direct (`Services/CoreConfig/V2ray/V2rayRoutingService.cs:239-278`, emitted at
62
+ `:16-30`). This is the most load-bearing lesson in the whole study: a control plane routed through
63
+ the thing it controls is the most likely way to brick a machine.
64
+ 6. **Teardown that is enforced by the OS, plus reaping at start.** Cores are assigned to a
65
+ kill-on-close job object (`Services/WindowsJobService.cs:15-18`; `Manager/CoreManager.cs:360,365-376`)
66
+ so a GUI crash still kills them, and stale devices are swept at the *next* launch
67
+ (`Common/WindowsUtils.cs:55-75`). Our analogue: reap stale state and half-open punches on start
68
+ instead of trusting a graceful exit.
69
+ 7. **Validate, warn, fall back to a safe value.** Errors and warnings are separate lists; an
70
+ unresolvable tag falls back with a warning; invalid values are blanked and reported
71
+ (`Handler/Builder/CoreConfigContextBuilder.cs:3-26,70-136`).
72
+ 8. **Ordered rules with a documented catch-all.** Tunnel/DNS/loop rules first, user rules next,
73
+ catch-all last (`V2rayRoutingService.cs:9-64,215-237`). Only relevant to us if routing ever enters
74
+ the picture — which, per the decision above, it has not.
75
+
76
+ ## What we deliberately are not copying
77
+
78
+ Everything requiring an external core or administrator rights: Wintun/WFP plumbing, WinINet/RAS proxy
79
+ configuration with a registry fallback, PAC serving, route supernet arithmetic, TUN address pools,
80
+ per-process/per-domain routing, and core-level observatories/balancers. None of it is our shape, and
81
+ most of it is licensed work we must not take even if it were.
82
+
83
+ ## One thing v2rayN does *not* do, which we should not admire
84
+
85
+ There is **no core auto-restart and no core-death notification**: the `Exited` handler only
86
+ unsubscribes log handlers (`Services/ProcessService.cs:132-142`). Its safety net is the job object
87
+ killing things reliably — not noticing and repairing. We already learned this the hard way: this
88
+ project's agent was twice found dead with a `0xC000013A` and nothing restarting it. Reliability by
89
+ construction is better than reliability by hope, and where we cannot have the former we now at least
90
+ have a watchdog that notices.
@@ -56,6 +56,7 @@ param(
56
56
  [string]$Bin = '',
57
57
  [string]$AgentArgs = '',
58
58
  [int]$RestartBackoffMs = 60000,
59
+ [int]$WatchdogMinutes = 5,
59
60
  [switch]$Interactive,
60
61
  [switch]$Uninstall,
61
62
  [switch]$WhatIfOnly
@@ -133,11 +134,34 @@ $json = $config | ConvertTo-Json -Depth 4
133
134
  Write-Host "wrote $configPath"
134
135
 
135
136
  $action = New-ScheduledTaskAction -Execute $node -Argument ($arguments -join ' ') -WorkingDirectory $PSScriptRoot
137
+ # ---------------------------------------------------------------------------------------------
138
+ # SELF-HEALING WITHOUT ELEVATION
139
+ #
140
+ # Measured twice on this machine: the whole process tree (supervisor + agent) is killed with
141
+ # 0xC000013A (STATUS_CONTROL_C_EXIT -- its console went away), the log ends mid-sentence, and Task
142
+ # Scheduler does NOT bring it back even with RestartCount 999: the task simply ends. The fleet then
143
+ # silently loses the machine, which is exactly the failure a supervisor was supposed to prevent --
144
+ # a supervisor that shares the console dies with it.
145
+ #
146
+ # A repeating trigger fixes that without any administrator right: combined with
147
+ # `MultipleInstances IgnoreNew` the task means "be running, or start", so a death from any cause is
148
+ # repaired at the next tick. The window is the repetition interval, and that is the honest trade:
149
+ # up to N minutes offline after a console-close death, versus needing elevation for a session-0
150
+ # service (the real fix, and the default mode above).
151
+ # ---------------------------------------------------------------------------------------------
152
+ $repetition = if ($Interactive) {
153
+ # `[TimeSpan]::MaxValue` is NOT a valid duration here -- Task Scheduler rejects it and the whole
154
+ # registration fails (measured: the script had already stopped the old task, so the machine was
155
+ # left with no agent at all). Ten years is the idiomatic stand-in for "repeat indefinitely".
156
+ New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
157
+ -RepetitionInterval (New-TimeSpan -Minutes $WatchdogMinutes) `
158
+ -RepetitionDuration ([TimeSpan]::FromDays(3650))
159
+ } else { $null }
136
160
  # AtStartup only makes sense for the session-0 (S4U) shape, which also runs whether or not anyone is
137
161
  # signed in. Registering an AtStartup trigger on an interactive task is refused without elevation --
138
162
  # measured, and the reason this list is conditional rather than always two entries.
139
163
  $triggers = if ($Interactive) {
140
- @(New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME")
164
+ @((New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME"), $repetition)
141
165
  } else {
142
166
  @(
143
167
  (New-ScheduledTaskTrigger -AtStartup),
@@ -35,7 +35,7 @@
35
35
  //
36
36
  // Nothing here is Windows-specific except the defaults; the same supervisor works on POSIX hosts.
37
37
  import { spawn } from 'node:child_process';
38
- import { createWriteStream, existsSync, mkdirSync, readFileSync } from 'node:fs';
38
+ import { createWriteStream, existsSync, mkdirSync, openSync, readFileSync } from 'node:fs';
39
39
  import { dirname, join } from 'node:path';
40
40
 
41
41
  /** Minimal `--flag value` / `--flag` parser: no dependency, no surprises. */
@@ -149,6 +149,9 @@ if (!existsSync(bin)) {
149
149
 
150
150
  mkdirSync(dirname(logPath), { recursive: true });
151
151
  const log = createWriteStream(logPath, { flags: 'a' });
152
+ // A second handle on the same file, as a descriptor: this is what the detached agent writes into,
153
+ // because a descriptor is not connected to this process's console or to any pipe of ours.
154
+ const logFd = openSync(logPath, 'a');
152
155
  const stamp = () => new Date().toISOString();
153
156
  const say = (line) => {
154
157
  console.log(line);
@@ -177,14 +180,33 @@ let runs = 0;
177
180
  while (!stopping) {
178
181
  runs += 1;
179
182
  const startedAt = Date.now();
180
- const child = spawn(process.execPath, argv, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
181
- child.stdout.on('data', (chunk) => {
182
- process.stdout.write(chunk);
183
- log.write(chunk);
184
- });
185
- child.stderr.on('data', (chunk) => {
186
- process.stderr.write(chunk);
187
- log.write(chunk);
183
+ // ---------------------------------------------------------------------------------------------
184
+ // THE AGENT MUST NOT SHARE THIS PROCESS'S CONSOLE.
185
+ //
186
+ // Measured twice on the fleet: the whole tree -- supervisor and agent together -- is killed with
187
+ // 0xC000013A (STATUS_CONTROL_C_EXIT, "the console went away"), the log ends mid-sentence, and
188
+ // nothing restarts it. A supervisor that shares the console dies with the thing it supervises,
189
+ // which was its one job. A study of v2rayN (GPL-3.0, read-only; see deploy/networking/LESSONS.md)
190
+ // turned up why its core cannot die this way: it is started with no console window and redirected
191
+ // stdio at all.
192
+ //
193
+ // `detached: true` plus stdio that is NOT connected to this process is the documented way to make
194
+ // a long-running child independent of its parent's console: with `detached`, a long-running child
195
+ // "will not stay running in the background after the parent exits unless it is provided with a
196
+ // `stdio` configuration that is not connected to the parent". So the agent gets file descriptors --
197
+ // never `inherit`, never pipes -- and that is the whole of the console-severing change.
198
+ //
199
+ // NOT `child.unref()`. It was the obvious-looking addition and it is wrong here: unref'ing the
200
+ // child leaves this process with no handle at all, so its event loop drains and **the supervisor
201
+ // exits immediately after spawning the agent** -- measured, and caught by noticing that no
202
+ // supervisor process existed at all. `unref()` is for a parent that intends to leave; this one
203
+ // exists to stay. Pipes would be the other trap: a detached child writing into a pipe whose reader
204
+ // has died blocks instead of failing loudly, which is why the log is a descriptor.
205
+ // ---------------------------------------------------------------------------------------------
206
+ const child = spawn(process.execPath, argv, {
207
+ detached: true,
208
+ stdio: ['ignore', logFd, logFd],
209
+ windowsHide: true,
188
210
  });
189
211
 
190
212
  const result = await new Promise((resolve) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "DSH: Windows2MacOS — drive the same project on online Windows and macOS machines from one DeepSeek account, across networks and regions. P2P hole punching is the default path (relay + shared server as rendezvous, ledger and fallback), with a bundled STUN responder, Tailscale/public-VPS/tunnel shapes, Rabbit relay + per-machine Localside + eight DSH tools (replicate / split), nine aggregate verdicts and signed requests.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -115,7 +115,7 @@ const MAX_STAGES = 16;
115
115
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
116
116
  * overrides it at runtime for tests and packagers.
117
117
  */
118
- const PLUGIN_VERSION = '0.5.0';
118
+ const PLUGIN_VERSION = '0.5.1';
119
119
 
120
120
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
121
121
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;